├── .github ├── dependabot.yml ├── pr_text.py └── workflows │ ├── python-package.yml │ └── translations.yml ├── .gitignore ├── CHANGES.rst ├── COPYING ├── HACKING.rst ├── MANIFEST.in ├── README.rst ├── TRANSLATIONS.rst ├── completions ├── bash │ ├── udiskie │ ├── udiskie-info │ ├── udiskie-mount │ └── udiskie-umount └── zsh │ ├── _udiskie │ ├── _udiskie-canonical_paths │ ├── _udiskie-mount │ └── _udiskie-umount ├── doc ├── .gitignore ├── Makefile ├── asciidoc.conf └── udiskie.8.txt ├── example └── config.yml ├── lang ├── Makefile ├── de.po ├── en_US.po ├── es_ES.po ├── it_IT.po ├── report.sh ├── ru_RU.po ├── sk_SK.po ├── tr_TR.po ├── udiskie.pot └── zh_CN.po ├── screenshot.png ├── setup.cfg ├── setup.py ├── test ├── test_cache.py └── test_match.py └── udiskie ├── __init__.py ├── appindicator.py ├── async_.py ├── automount.py ├── cache.py ├── cli.py ├── common.py ├── config.py ├── dbus.py ├── depend.py ├── icons ├── __init__.py ├── udiskie-checkbox-checked.svg ├── udiskie-checkbox-unchecked.svg ├── udiskie-detach.svg ├── udiskie-eject.svg ├── udiskie-lock.svg ├── udiskie-mount.svg ├── udiskie-submenu.svg ├── udiskie-unlock.svg └── udiskie-unmount.svg ├── locale.py ├── mount.py ├── notify.py ├── password_dialog.ui ├── prompt.py ├── tray.py └── udisks2.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/pr_text.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | def read_tsv(fname): 4 | with open(fname) as f: 5 | return [line.strip('\n').split('\t') for line in f] 6 | 7 | 8 | def make_table_dict(columns, *rows): 9 | return { 10 | row[0]: dict(zip(columns[1:], row[1:])) 11 | for row in rows 12 | } 13 | 14 | 15 | def dicts_items(a, b): 16 | for k, va in a.items(): 17 | yield (k, va, b.get(k)) 18 | 19 | 20 | def summarize_entry(a, b, key): 21 | va = int(a[key]) 22 | vb = int(b[key]) 23 | if vb > va: 24 | return f'{va} ↗️ {vb}' 25 | elif vb == va: 26 | return f'{vb}' 27 | else: 28 | return f'{va} ↘️ {vb}' 29 | 30 | 31 | def tabularize(*rows, align): 32 | rows = [[' {} '.format(x) for x in row] for row in rows] 33 | cols = list(zip(*rows)) 34 | widths = [ 35 | max(3, max(len(x) for x in col)) 36 | for col in cols 37 | ] 38 | lines = [ 39 | ['{:{}{}}'.format(val, align_, width) 40 | for val, align_, width in zip(row, align, widths)] 41 | for row in rows 42 | ] 43 | INFO = {'<': ':{}-', '^': ':{}:', '>': '-{}:'} 44 | lines.insert(1, [ 45 | INFO[align_].format('-' * (width - 2)) 46 | for align_, width in zip(align, widths) 47 | ]) 48 | return ''.join([ 49 | '|{}|\n'.format('|'.join(line)) 50 | for line in lines 51 | ]) 52 | 53 | 54 | def main(url='https://github.com/coldfix/udiskie/blob/master/lang/'): 55 | href = '[{0}]({1}{0})' if url else '{0}' 56 | before = make_table_dict(*read_tsv('before.tsv')) 57 | after = make_table_dict(*read_tsv('after.tsv')) 58 | columns = [ 59 | 'File', 60 | 'Untranslated', 61 | 'Translated', 62 | 'Out-of-date', 63 | 'Obsolete', 64 | '% Complete', 65 | ] 66 | summary = [ 67 | [ 68 | href.format(filename, url), 69 | summarize_entry(ra, rb, 'Untranslated'), 70 | summarize_entry(ra, rb, 'Translated'), 71 | summarize_entry(ra, rb, 'Fuzzy'), 72 | summarize_entry(ra, rb, 'Obsolete'), 73 | summarize_entry(ra, rb, '%'), 74 | ] 75 | for filename, rb, ra in dicts_items(before, after) 76 | ] 77 | print(tabularize(columns, *summary, align='<>>>>>')) 78 | 79 | 80 | if __name__ == '__main__': 81 | main() 82 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python Package 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest] 12 | python: 13 | - "3.10" 14 | - "3.11" 15 | - "3.12" 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python }} 22 | 23 | - name: Install build dependencies 24 | run: | 25 | sudo apt-get install -qy \ 26 | gettext \ 27 | libgirepository1.0-dev 28 | 29 | - run: pip install -U pip 30 | - run: pip install setuptools wheel 31 | 32 | - run: python setup.py sdist bdist_wheel 33 | - run: pip install dist/*.whl 34 | - run: pip install twine flake8 35 | 36 | - run: twine check dist/* 37 | - run: flake8 38 | - run: python test/test_match.py 39 | 40 | - uses: actions/upload-artifact@v4 41 | with: {name: dist, path: dist/} 42 | if: matrix.python == '3.12' 43 | 44 | deploy: 45 | name: Upload release 46 | runs-on: ubuntu-latest 47 | needs: build 48 | if: startsWith(github.ref, 'refs/tags/v') && success() 49 | environment: 50 | name: pypi 51 | permissions: 52 | id-token: write 53 | 54 | steps: 55 | - uses: actions/download-artifact@v4.1.8 56 | with: {name: dist, path: dist/} 57 | - uses: pypa/gh-action-pypi-publish@release/v1 58 | # with: 59 | # repository-url: https://test.pypi.org/legacy/ 60 | -------------------------------------------------------------------------------- /.github/workflows/translations.yml: -------------------------------------------------------------------------------- 1 | name: Translations 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | update_po: 9 | name: Update language files 10 | runs-on: ubuntu-latest 11 | defaults: 12 | run: 13 | shell: bash -eo pipefail {0} 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: 3.x 20 | 21 | - run: sudo apt-get install -qy gettext 22 | 23 | - run: ./lang/report.sh | tee before.tsv | column -t 24 | - run: make -BC lang 25 | - run: ./lang/report.sh | tee after.tsv | column -t 26 | - run: .github/pr_text.py | tee summary.md 27 | 28 | - run: | 29 | echo >>$GITHUB_OUTPUT "summary<>$GITHUB_OUTPUT >$GITHUB_OUTPUT "EOS_SUMMARY" 32 | id: report 33 | - run: rm before.tsv after.tsv summary.md 34 | 35 | - uses: peter-evans/create-pull-request@v7 36 | with: 37 | branch: ${{ github.ref_name }}_langfiles 38 | commit-message: Update language files 39 | title: Update language files on ${{ github.ref_name }} 40 | body: ${{ steps.report.outputs.summary }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python binaries 2 | *.py[cod] 3 | CHANGES 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Packages 9 | *.egg 10 | *.egg-info 11 | dist 12 | build 13 | eggs 14 | parts 15 | bin 16 | var 17 | sdist 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | __pycache__ 23 | MANIFEST 24 | 25 | # Installer logs 26 | pip-log.txt 27 | 28 | # Unit test / coverage reports 29 | .coverage 30 | .tox 31 | nosetests.xml 32 | 33 | # translations 34 | lang/*~ 35 | lang/**/*.mo 36 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2012 Byron Clark 2 | (c) 2013-2023 Thomas Gläßle 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /HACKING.rst: -------------------------------------------------------------------------------- 1 | Hacking 2 | ------- 3 | 4 | *udiskie* is developed on github_. Feel free to contribute patches as pull 5 | requests here. If you don't have nor want a github account, you can send me 6 | the relevant files via email. 7 | 8 | Further resources: 9 | 10 | - `UDisks1 API`_ 11 | - `UDisks2 API`_ 12 | - `PyGObject APIs`_ 13 | 14 | .. _github: https://github.com/coldfix/udiskie 15 | .. _PEP8: http://www.python.org/dev/peps/pep-0008/ 16 | .. _`unit tests`: http://docs.python.org/2/library/unittest.html 17 | 18 | .. _`UDisks1 API`: http://udisks.freedesktop.org/docs/1.0.5/ 19 | .. _`UDisks2 API`: http://udisks.freedesktop.org/docs/latest/ 20 | .. _`PyGObject APIs`: http://lazka.github.io/pgi-docs/index.html 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include doc/*.txt 2 | include doc/asciidoc.conf 3 | include doc/Makefile 4 | graft completions 5 | recursive-include lang *.pot *.po 6 | recursive-include udiskie *.ui 7 | recursive-include udiskie/icons *.svg 8 | include CONTRIBUTORS COPYING LICENSE 9 | include README.rst CHANGES.rst 10 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | udiskie 3 | ======= 4 | 5 | |Version| |License| |Translations| 6 | 7 | *udiskie* is a udisks2_ front-end that allows to manage removable media such 8 | as CDs or flash drives from userspace. 9 | 10 | |Screenshot| 11 | 12 | Its features include: 13 | 14 | - automount removable media 15 | - notifications 16 | - tray icon 17 | - command line tools for manual un-/mounting 18 | - LUKS encrypted devices 19 | - unlocking with keyfiles (requires udisks 2.6.4) 20 | - loop devices (mounting iso archives) 21 | - password caching (requires python keyutils 0.3) 22 | 23 | All features can be individually enabled or disabled. 24 | 25 | **NOTE:** support for python2 and udisks1 have been removed. If you need a 26 | version of udiskie that supports python2, please check out the ``1.7.X`` 27 | releases or the ``maint-1.7`` branch. 28 | 29 | .. _udisks2: https://www.freedesktop.org/wiki/Software/udisks 30 | 31 | - `Documentation`_ 32 | 33 | - Usage_ 34 | - Installation_ 35 | - `Debug Info`_ 36 | - Troubleshooting_ 37 | - FAQ_ 38 | 39 | - `Man page`_ 40 | - `Source Code`_ 41 | - `Latest Release`_ 42 | - `Issue Tracker`_ 43 | 44 | .. _Documentation: https://github.com/coldfix/udiskie/wiki 45 | .. _Usage: https://github.com/coldfix/udiskie/wiki/Usage 46 | .. _Installation: https://github.com/coldfix/udiskie/wiki/Installation 47 | .. _Debug Info: https://github.com/coldfix/udiskie/wiki/Debug-Info 48 | .. _Troubleshooting: https://github.com/coldfix/udiskie/wiki/Troubleshooting 49 | .. _FAQ: https://github.com/coldfix/udiskie/wiki/FAQ 50 | 51 | .. _Man Page: https://raw.githubusercontent.com/coldfix/udiskie/master/doc/udiskie.8.txt 52 | .. _Source Code: https://github.com/coldfix/udiskie 53 | .. _Latest Release: https://pypi.python.org/pypi/udiskie/ 54 | .. _Issue Tracker: https://github.com/coldfix/udiskie/issues 55 | .. _Roadmap: https://github.com/coldfix/udiskie/blob/master/HACKING.rst#roadmap 56 | 57 | 58 | .. Badges: 59 | 60 | .. |Version| image:: https://img.shields.io/pypi/v/udiskie.svg 61 | :target: https://pypi.python.org/pypi/udiskie 62 | :alt: Version 63 | 64 | .. |License| image:: https://img.shields.io/pypi/l/udiskie.svg 65 | :target: https://github.com/coldfix/udiskie/blob/master/COPYING 66 | :alt: License: MIT 67 | 68 | .. |Translations| image:: http://weblate.coldfix.de/widgets/udiskie/-/udiskie/svg-badge.svg 69 | :target: http://weblate.coldfix.de/engage/udiskie/ 70 | :alt: Translations 71 | 72 | .. |Screenshot| image:: https://raw.githubusercontent.com/coldfix/udiskie/master/screenshot.png 73 | :target: https://raw.githubusercontent.com/coldfix/udiskie/master/screenshot.png 74 | :alt: Screenshot 75 | -------------------------------------------------------------------------------- /TRANSLATIONS.rst: -------------------------------------------------------------------------------- 1 | Translations 2 | ------------ 3 | 4 | Translations by users are always welcome. The corresponding files are in the 5 | `lang`_ subfolder. In order to create a new translation, find out the locale 6 | name for your language, e.g. ``es_ES`` for Spanish, and create a translation 7 | file in the ``lang`` folder as follows:: 8 | 9 | cd lang 10 | make es_ES.po 11 | 12 | or simply copy the `udiskie.pot`_ to a ``.po`` file with the name of the 13 | target locale and start editing. It's also best to fill in your name and email 14 | address. 15 | 16 | The translations may become outdated as udiskie changes. If you notice an 17 | outdated translation, please edit the corresponding ``.po`` file in submit a 18 | patch, even for very small changes. 19 | 20 | In order to test udiskie with your locally edited translation files, type 21 | (still from the ``lang`` folder):: 22 | 23 | export TEXTDOMAINDIR=$PWD/../build/locale 24 | export LANG=es_ES.UTF-8 25 | 26 | make mo 27 | 28 | udiskie 29 | 30 | .. _lang: https://github.com/coldfix/udiskie/tree/master/lang 31 | .. _udiskie.pot: https://raw.githubusercontent.com/coldfix/udiskie/master/lang/udiskie.pot 32 | -------------------------------------------------------------------------------- /completions/bash/udiskie: -------------------------------------------------------------------------------- 1 | # udiskie completion 2 | 3 | _udiskie() 4 | { 5 | local cur prev opts 6 | _get_comp_words_by_ref cur prev 7 | 8 | opts=( 9 | '-h' 10 | '--help' 11 | '-V' 12 | '--version' 13 | '-v' 14 | '--verbose' 15 | '-q' 16 | '--quiet' 17 | '-c' 18 | '--config' 19 | '-C' 20 | '--no-config' 21 | '-a' 22 | '--automount' 23 | '-A' 24 | '--no-automount' 25 | '-n' 26 | '--notify' 27 | '-N' 28 | '--no-notify' 29 | '-t' 30 | '--tray' 31 | '-s' 32 | '--smart-tray' 33 | '-T' 34 | '--no-tray' 35 | '-m' 36 | '--menu' 37 | '-p' 38 | '--password-prompt' 39 | '-P' 40 | '--no-password-prompt' 41 | '-f' 42 | '--file-manager' 43 | '-F' 44 | '--no-file-manager' 45 | '--terminal' 46 | '--no-terminal' 47 | '--appindicator' 48 | '--no-appindicator' 49 | '--password-cache' 50 | '--no-password-cache' 51 | '--event-hook' 52 | '--no-event-hook' 53 | '--menu-checkbox-workaround' 54 | '--no-menu-checkbox-workaround' 55 | '--menu-update-workaround' 56 | '--no-menu-update-workaround' 57 | ) 58 | 59 | case "$prev" in 60 | -c|--config) 61 | COMPREPLY=($(compgen -f -- "$cur")) 62 | return 63 | ;; 64 | -f|--file-manager|--event-hook|-p|--password-prompt|--terminal) 65 | COMPREPLY=($(compgen -c -- "$cur")) 66 | return 67 | ;; 68 | -m|--menu) 69 | COMPREPLY=($(compgen -W "flat nested" -- "$cur")) 70 | return 71 | ;; 72 | --password-cache) 73 | return 74 | ;; 75 | esac 76 | 77 | COMPREPLY=($(compgen -W "${opts[*]}" -- "$cur")) 78 | 79 | } && 80 | complete -F _udiskie udiskie 81 | 82 | # ex:et ts=2 sw=2 ft=sh 83 | -------------------------------------------------------------------------------- /completions/bash/udiskie-info: -------------------------------------------------------------------------------- 1 | # udiskie-info completion 2 | 3 | _udiskie_info() 4 | { 5 | local cur prev devs opts 6 | _get_comp_words_by_ref cur prev 7 | 8 | devs=( $(udiskie-info -a) ) 9 | 10 | opts=( 11 | '-h' 12 | '--help' 13 | '-V' 14 | '--version' 15 | '-v' 16 | '--verbose' 17 | '-q' 18 | '--quiet' 19 | '-c' 20 | '--config' 21 | '-C' 22 | '--no-config' 23 | '-a' 24 | '--all' 25 | '-o' 26 | '--output' 27 | '-f' 28 | '--filter' 29 | ) 30 | 31 | case $prev in 32 | -c|--config) 33 | COMPREPLY=($(compgen -f -- "$cur")) 34 | return 35 | ;; 36 | -o|--output|-f|--filter) 37 | return 38 | ;; 39 | esac 40 | 41 | COMPREPLY=($(compgen -W "${devs[*]} ${opts[*]}" -- "$cur")) 42 | 43 | } && 44 | complete -F _udiskie_info udiskie-info 45 | 46 | # ex:et ts=2 sw=2 ft=sh 47 | -------------------------------------------------------------------------------- /completions/bash/udiskie-mount: -------------------------------------------------------------------------------- 1 | # udiskie-mount completion 2 | 3 | _udiskie_mount() 4 | { 5 | local cur prev devs opts 6 | _get_comp_words_by_ref cur prev 7 | 8 | devs=( $(udiskie-info -a) ) 9 | 10 | opts=( 11 | '-h' 12 | '--help' 13 | '-V' 14 | '--version' 15 | '-v' 16 | '--verbose' 17 | '-q' 18 | '--quiet' 19 | '-c' 20 | '--config' 21 | '-C' 22 | '--no-config' 23 | '-a' 24 | '--all' 25 | '-r' 26 | '--recursive' 27 | '-R' 28 | '--no-recursive' 29 | '-o' 30 | '--options' 31 | '-p' 32 | '--password-prompt' 33 | '-P' 34 | '--no-password-prompt' 35 | ) 36 | 37 | case $prev in 38 | -c|--config) 39 | COMPREPLY=($(compgen -f -- "$cur")) 40 | return 41 | ;; 42 | -o|--options) 43 | return 44 | ;; 45 | -p|--password-prompt) 46 | COMPREPLY=($(compgen -c -- "$cur")) 47 | return 48 | ;; 49 | esac 50 | 51 | COMPREPLY=($(compgen -W "${devs[*]} ${opts[*]}" -- "$cur")) 52 | 53 | } && 54 | complete -F _udiskie_mount udiskie-mount 55 | 56 | # ex:et ts=2 sw=2 ft=sh 57 | -------------------------------------------------------------------------------- /completions/bash/udiskie-umount: -------------------------------------------------------------------------------- 1 | # udiskie-umount completion 2 | 3 | _udiskie_umount() 4 | { 5 | local cur prev devs opts 6 | _get_comp_words_by_ref cur prev 7 | 8 | devs=( $(udiskie-info -a) ) 9 | 10 | opts=( 11 | '-h' 12 | '--help' 13 | '-V' 14 | '--version' 15 | '-v' 16 | '--verbose' 17 | '-q' 18 | '--quiet' 19 | '-c' 20 | '--config' 21 | '-C' 22 | '--no-config' 23 | '-a' 24 | '--all' 25 | '-d' 26 | '--detach' 27 | '-D' 28 | '--no-detach' 29 | '-e' 30 | '--eject' 31 | '-E' 32 | '--no-eject' 33 | '-f' 34 | '--force' 35 | '-F' 36 | '--no-force' 37 | '-l' 38 | '--lock' 39 | '-L' 40 | '--no-lock' 41 | ) 42 | 43 | case $prev in 44 | -c|--config) 45 | COMPREPLY=($(compgen -f -- "$cur")) 46 | return 47 | ;; 48 | esac 49 | 50 | COMPREPLY=($(compgen -W "${devs[*]} ${opts[*]}" -- "$cur")) 51 | 52 | } && 53 | complete -F _udiskie_umount udiskie-umount 54 | 55 | # ex:et ts=2 sw=2 ft=sh 56 | -------------------------------------------------------------------------------- /completions/zsh/_udiskie: -------------------------------------------------------------------------------- 1 | #compdef udiskie 2 | # vim: ft=zsh sts=2 sw=2 ts=2 3 | 4 | function _udiskie 5 | { 6 | local context curcontext="$curcontext" line state ret=1 7 | local args tmp 8 | typeset -A opt_args 9 | 10 | args=( 11 | '(- *)'{-h,--help}"[show help]" 12 | '(- *)'{-V,--version}"[show version]" 13 | '(-q)'{-v,--verbose}"[more output]" 14 | '(-v)'{-q,--quiet}"[less output]" 15 | '(-C)'{-c,--config}"[set config file]:file:_files" 16 | '(-c)'{-C,--no-config}"[don't use config file]" 17 | '(-A)'{-a,--automount}"[automount new devices]" 18 | '(-a)'{-A,--no-automount}"[disable automounting]" 19 | '(-N)'{-n,--notify}"[show popup notifications]" 20 | '(-n)'{-N,--no-notify}"[disable notifications]" 21 | '(--no-appindicator)'--appindicator"[use appindicator for status icon]" 22 | '(--appindicator)'--no-appindicator"[don't use appindicator]" 23 | '(-T -s)'{-t,--tray}"[show tray icon]" 24 | '(-T -t)'{-s,--smart-tray}"[auto hide tray icon]" 25 | '(-t -s)'{-T,--no-tray}"[disable tray icon]" 26 | {-m,--menu}"[set behaviour for tray menu]:traymenu:(flat nested)" 27 | '(--no-password-cache)'--password-cache"[set timeout for passwords of encrypted devices to N minutes]:minutes" 28 | '(--password-cache)'--no-password-cache"[don't cache passwords for encrypted devices]" 29 | '(-P)'{-p,--password-prompt}"[Command for password retrieval]:passwordialog:->pprompt" 30 | '(-p)'{-P,--no-password-prompt}"[Disable unlocking]" 31 | '(-F)'{-f,--file-manager}"[set program for browsing directories]:filemanager:_path_commands" 32 | '(-f)'{-F,--no-file-manager}"[disable browsing]" 33 | '(--no-event-hook)'--event-hook"[execute this command on events]:minutes" 34 | '(--event-hook)'--no-event-hook"[don't execute event handler]" 35 | ) 36 | _arguments -C -s "$args[@]" && ret=0 37 | 38 | case $state in 39 | pprompt) 40 | _alternative \ 41 | 'builtins:builtin prompt:(builtin:tty builtin:gui)' \ 42 | 'commands:command name:_path_commands' \ 43 | && ret=0 44 | ;; 45 | esac 46 | 47 | return ret 48 | } 49 | 50 | _udiskie "$@" 51 | -------------------------------------------------------------------------------- /completions/zsh/_udiskie-canonical_paths: -------------------------------------------------------------------------------- 1 | #autoload 2 | 3 | # NOTE: 4 | # This file is a copy of the upstream _canonical_paths file that was modified 5 | # to fix a problem with device names that contain spaces (see #253). 6 | # The fix was taken from a discussion on the zsh-workers mailing list, see: 7 | # https://www.zsh.org/mla/workers/2022/msg01377.html 8 | # The original file is usually installed at: 9 | # /usr/share/zsh/functions/Completion/Unix/_canonical_paths 10 | 11 | 12 | # This completion function completes all paths given to it, and also tries to 13 | # offer completions which point to the same file as one of the paths given 14 | # (relative path when an absolute path is given, and vice versa; when ..'s are 15 | # present in the word to be completed, and some paths got from symlinks). 16 | 17 | # Usage: _udiskie-canonical_paths [-A var] [-N] [-MJV12onfX] tag desc [paths...] 18 | 19 | # -A, if specified, takes the paths from the array variable specified. Paths 20 | # can also be specified on the command line as shown above. -N, if specified, 21 | # prevents canonicalizing the paths given before using them for completion, in 22 | # case they are already so. `tag' and `desc' arguments are well, obvious :) In 23 | # addition, the options -M, -J, -V, -1, -2, -o, -n, -F, -x, -X are passed to 24 | # compadd. 25 | 26 | _udiskie-canonical_paths_add_paths () { 27 | # origpref = original prefix 28 | # expref = expanded prefix 29 | # curpref = current prefix 30 | # canpref = canonical prefix 31 | # rltrim = suffix to trim and readd 32 | local origpref=$1 expref rltrim curpref canpref subdir 33 | [[ $2 != add ]] && matches=() 34 | expref=${~origpref} 2>/dev/null 35 | [[ $origpref == (|*/). ]] && rltrim=. 36 | curpref=${${expref%$rltrim}:-./} 37 | canpref=$curpref:P 38 | [[ $curpref == */ && $canpref == *[^/] ]] && canpref+=/ 39 | canpref+=$rltrim 40 | [[ $expref == *[^/] && $canpref == */ ]] && origpref+=/ 41 | 42 | # Append to $matches the subset of $files that matches $canpref. 43 | if [[ $canpref == $origpref ]]; then 44 | # This codepath honours any -M matchspec parameters. 45 | () { 46 | local -a tmp_buffer 47 | compadd -A tmp_buffer "$__gopts[@]" -a files 48 | matches+=( "${(@)tmp_buffer/$canpref/$origpref}" ) 49 | } 50 | else 51 | # ### Ideally, this codepath would do what the 'if' above does, 52 | # ### but telling compadd to pretend the "word on the command line" 53 | # ### is ${"the word on the command line"/$origpref/$canpref}. 54 | # ### The following approximates that. 55 | matches+=(${(q)${(M)files:#$canpref*}/$canpref/$origpref}) 56 | fi 57 | 58 | for subdir in $expref?*(@); do 59 | _udiskie-canonical_paths_add_paths ${subdir/$expref/$origpref} add 60 | done 61 | } 62 | 63 | _udiskie-canonical_paths() { 64 | # The following parameters are used by callee functions: 65 | # __gopts 66 | # matches 67 | # files 68 | # (possibly others) 69 | 70 | local __index 71 | typeset -a __gopts __opts 72 | 73 | zparseopts -D -a __gopts M+: J+: V+: o+: 1 2 n F: x+: X+: A:=__opts N=__opts 74 | 75 | : ${1:=canonical-paths} ${2:=path} 76 | 77 | __index=$__opts[(I)-A] 78 | (( $__index )) && set -- $@ ${(P)__opts[__index+1]} 79 | 80 | local expl ret=1 tag=$1 desc=$2 81 | 82 | shift 2 83 | 84 | if ! zmodload -F zsh/stat b:zstat 2>/dev/null; then 85 | _wanted "$tag" expl "$desc" compadd $__gopts $@ && ret=0 86 | return ret 87 | fi 88 | 89 | typeset REPLY 90 | typeset -a matches files 91 | 92 | if (( $__opts[(I)-N] )); then 93 | files=($@) 94 | else 95 | files+=($@:P) 96 | fi 97 | 98 | local base=$PREFIX 99 | typeset -i blimit 100 | 101 | _udiskie-canonical_paths_add_paths $base 102 | 103 | if [[ -z $base ]]; then 104 | _udiskie-canonical_paths_add_paths / add 105 | elif [[ $base == ..(/.(|.))#(|/) ]]; then 106 | 107 | # This style controls how many parent directory links (..) to chase searching 108 | # for possible completions. The default is 8. Note that this chasing is 109 | # triggered only when the user enters at least a .. and the path completed 110 | # contains only . or .. components. A value of 0 turns off .. link chasing 111 | # altogether. 112 | 113 | zstyle -s ":completion:${curcontext}:$tag" \ 114 | canonical-paths-back-limit blimit || blimit=8 115 | 116 | if [[ $base != */ ]]; then 117 | [[ $base != *.. ]] && base+=. 118 | base+=/ 119 | fi 120 | until [[ $base.. -ef $base || blimit -le 0 ]]; do 121 | base+=../ 122 | _udiskie-canonical_paths_add_paths $base add 123 | blimit+=-1 124 | done 125 | fi 126 | 127 | _wanted "$tag" expl "$desc" compadd $__gopts -Q -U -a matches && ret=0 128 | 129 | return ret 130 | } 131 | 132 | _udiskie-canonical_paths "$@" 133 | -------------------------------------------------------------------------------- /completions/zsh/_udiskie-mount: -------------------------------------------------------------------------------- 1 | #compdef udiskie-mount 2 | # vim: ft=zsh sts=2 sw=2 ts=2 3 | 4 | function _udiskie-mount 5 | { 6 | local context curcontext="$curcontext" line state ret=1 7 | local args tmp 8 | typeset -A opt_args 9 | 10 | args=( 11 | '(- *)'{-h,--help}"[show help]" 12 | '(- *)'{-V,--version}"[show version]" 13 | '(-q)'{-v,--verbose}"[more output]" 14 | '(-v)'{-q,--quiet}"[less output]" 15 | '(-C)'{-c,--config}"[set config file]:file:_files" 16 | '(-c)'{-C,--no-config}"[don't use config file]" 17 | '(*)'{-a,--all}"[unmount all devices]" 18 | '(-R)'{-r,--recursive}"[recursively add devices]" 19 | '(-r)'{-R,--no-recursive}"[disable recursive mounting]" 20 | {-o,--options}"[set filesystem options]:file system option" 21 | '(-P)'{-p,--password-prompt}"[Command for password retrieval]:passwordialog:->pprompt" 22 | '(-p)'{-P,--no-password-prompt}"[Disable unlocking]" 23 | '*:dev or dir:->udevordir' 24 | ) 25 | _arguments -C -s "$args[@]" && ret=0 26 | 27 | case "$state" in 28 | pprompt) 29 | _alternative \ 30 | 'builtins:builtin prompt:(builtin:tty builtin:gui)' \ 31 | 'commands:command name:_path_commands' \ 32 | && ret=0 33 | ;; 34 | 35 | udevordir) 36 | local dev_tmp mp_tmp 37 | dev_tmp=( $(udiskie-info -a) ) 38 | _alternative \ 39 | 'device-paths: device path:_udiskie-canonical_paths -A dev_tmp -N device-paths device\ path' \ 40 | && ret=0 41 | ;; 42 | esac 43 | return ret 44 | } 45 | 46 | _udiskie-mount "$@" 47 | -------------------------------------------------------------------------------- /completions/zsh/_udiskie-umount: -------------------------------------------------------------------------------- 1 | #compdef udiskie-umount 2 | # vim: ft=zsh sts=2 sw=2 ts=2 3 | 4 | function _udiskie-umount 5 | { 6 | local context curcontext="$curcontext" line state ret=1 7 | typeset -A opt_args 8 | 9 | args=( 10 | '(- *)'{-h,--help}"[show help]" 11 | '(- *)'{-V,--version}"[show version]" 12 | '(-q)'{-v,--verbose}"[more output]" 13 | '(-v)'{-q,--quiet}"[less output]" 14 | '(-C)'{-c,--config}"[set config file]:file:_files" 15 | '(-c)'{-C,--no-config}"[don't use config file]" 16 | '(*)'{-a,--all}"[unmount all devices]" 17 | '(-D)'{-d,--detach}"[detach device]" 18 | '(-d)'{-D,--no-detach}"[don't detach device]" 19 | '(-E)'{-e,--eject}"[eject device]" 20 | '(-e)'{-E,--no-eject}"[don't eject device]" 21 | '(-F)'{-f,--force}"[recursive unmounting]" 22 | '(-f)'{-F,--no-force}"[no recursive unmountinng]" 23 | '(-L)'{-l,--lock}"[lock device after unmounting]" 24 | '(-l)'{-L,--no-lock}"[don't lock device]" 25 | '*:dev or dir:->udevordir' 26 | ) 27 | _arguments -C -s "$args[@]" && ret=0 28 | 29 | case "$state" in 30 | udevordir) 31 | local dev_tmp mp_tmp loop_tmp dev_detail 32 | 33 | # "${(@f)X}" means to use lines as separators 34 | dev_detail=( "${(@f)$(udiskie-info -a -o '{device_presentation}<:1:>{mount_path}<:2:>{is_filesystem}<:3:>{is_mounted}<:4:>{is_loop}<:5:>{loop_file}')}" ) 35 | 36 | # select: 'device_presentation' 37 | dev_tmp=( ${dev_detail%%<:1:>*} ) 38 | 39 | # filter: 'is_filesystem' and 'is_mounted' 40 | mp_tmp=( ${(M)dev_detail:#*<:2:>True<:3:>True<:4:>*} ) 41 | # select: 'mount_path' 42 | mp_tmp=( ${mp_tmp##*<:1:>} ) 43 | mp_tmp=( ${mp_tmp%%<:2:>*} ) 44 | 45 | # filter: 'is_loop' 46 | loop_tmp=( ${(M)dev_detail:#*<:3:>True<:5:>*} ) 47 | # select: 'mount_path' 48 | loop_tmp=( ${loop_tmp##*<:5:>} ) 49 | 50 | _alternative \ 51 | 'directories:mount point:_udiskie-canonical_paths -A mp_tmp -N directories mount\ point' \ 52 | 'device-paths: device path:_udiskie-canonical_paths -A dev_tmp -N device-paths device\ path' \ 53 | 'loop-files: loop file:_udiskie-canonical_paths -A loop_tmp -N loop-files loop\ file' \ 54 | && ret=0 55 | 56 | ;; 57 | esac 58 | return ret 59 | } 60 | 61 | _udiskie-umount "$@" 62 | -------------------------------------------------------------------------------- /doc/.gitignore: -------------------------------------------------------------------------------- 1 | udiskie.8 2 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | udiskie.8: udiskie.8.txt asciidoc.conf 2 | a2x --asciidoc-opts="-f asciidoc.conf" -f manpage -L udiskie.8.txt 3 | 4 | .PHONY: clean 5 | clean: 6 | rm -f udiskie.8 7 | -------------------------------------------------------------------------------- /doc/asciidoc.conf: -------------------------------------------------------------------------------- 1 | ## linkman: macro 2 | # Inspired by/borrowed from the GIT source tree at Documentation/asciidoc.conf 3 | # 4 | # Usage: linkman:command[manpage-section] 5 | # 6 | # Note, {0} is the manpage section, while {target} is the command. 7 | # 8 | # Show man link as: (
); if section is defined, else just show 9 | # the command. 10 | 11 | [macros] 12 | (?su)[\\]?(?Plinkman):(?P\S*?)\[(?P.*?)\]= 13 | 14 | [attributes] 15 | asterisk=* 16 | plus=+ 17 | caret=^ 18 | startsb=[ 19 | endsb=] 20 | tilde=~ 21 | 22 | ifdef::backend-docbook[] 23 | [linkman-inlinemacro] 24 | {0%{target}} 25 | {0#} 26 | {0#{target}{0}} 27 | {0#} 28 | endif::backend-docbook[] 29 | 30 | ifdef::backend-docbook[] 31 | ifndef::docbook-xsl-172[] 32 | # "unbreak" docbook-xsl v1.68 for manpages. v1.69 works with or without this. 33 | # v1.72 breaks with this because it replaces dots not in roff requests. 34 | [listingblock] 35 | {title} 36 | 37 | | 38 | 39 | {title#} 40 | endif::docbook-xsl-172[] 41 | endif::backend-docbook[] 42 | 43 | ifdef::doctype-manpage[] 44 | ifdef::backend-docbook[] 45 | [header] 46 | template::[header-declarations] 47 | 48 | 49 | {pacman_date} 50 | 51 | 52 | {mantitle} 53 | {manvolnum} 54 | udiskie 55 | udiskie 56 | 57 | 58 | {manname} 59 | {manpurpose} 60 | 61 | endif::backend-docbook[] 62 | endif::doctype-manpage[] 63 | 64 | ifdef::backend-xhtml11[] 65 | [linkman-inlinemacro] 66 | {target}{0?({0})} 67 | endif::backend-xhtml11[] 68 | -------------------------------------------------------------------------------- /doc/udiskie.8.txt: -------------------------------------------------------------------------------- 1 | ///// 2 | vim:set ts=4 sw=4 syntax=asciidoc noet: 3 | ///// 4 | udiskie(8) 5 | ========== 6 | 7 | 8 | Name 9 | ---- 10 | udiskie - automounter for removable media 11 | 12 | 13 | Synopsis 14 | -------- 15 | 'udiskie' [OPTIONS] 16 | 17 | 'udiskie-mount' [OPTIONS] (-a | DEVICE...) 18 | 19 | 'udiskie-umount' [OPTIONS] (-a | PATH...) 20 | 21 | 22 | Description 23 | ----------- 24 | *udiskie* is an udisks2 front-end written in python. Its main purpose is automatically mounting removable media, such as CDs or flash drives. It has optional mount notifications, a GTK tray icon and user level CLIs for manual mount and unmount operations. The media will be mounted in a new directory under '/media' or '/run/media/USER/', using the device name if possible. 25 | 26 | 27 | Common options 28 | -------------- 29 | *-h, \--help*:: 30 | Show help message and exit. 31 | 32 | *-V, \--version*:: 33 | Show help message and exit. 34 | 35 | *-v, \--verbose*:: 36 | Verbose output. 37 | 38 | *-q, \--quiet*:: 39 | Quiet output. 40 | 41 | *-c FILE, \--config=FILE*:: 42 | Specify config file. 43 | 44 | *-C, \--no-config*:: 45 | Don't use any config file. 46 | 47 | 48 | Shared Mount and Daemon options 49 | ------------------------------- 50 | 51 | *-p COMMAND, \--password-prompt=COMMAND*:: 52 | Password retrieval command. The string is formatted with the device attributes as keyword arguments, e.g.: 53 | 54 | -p "zenity --entry --hide-text --text 'Enter password for {device_presentation}:'" 55 | 56 | *-P, \--no-password-prompt*:: 57 | Disable unlocking of LUKS devices. 58 | 59 | 60 | Daemon options 61 | -------------- 62 | *-a, \--automount*:: 63 | Enable automounting new devices (default). 64 | 65 | *-A, \--no-automount*:: 66 | Disable automounting new devices. 67 | 68 | *-n, \--notify*:: 69 | Enable pop-up notifications (default). 70 | 71 | *-N, \--no-notify*:: 72 | Disable pop-up notifications. 73 | 74 | *-t, \--tray*:: 75 | Show tray icon. 76 | 77 | *-s, \--smart-tray*:: 78 | Show tray icon that automatically hides when there is no action available. 79 | 80 | *-T, \--no-tray*:: 81 | Disable tray icon (default). 82 | 83 | *-f PROGRAM, \--file-manager=PROGRAM*:: 84 | Set program to open mounted directories. Default is \'+xdg-open+'. Pass an empty string to disable this feature. 85 | 86 | *-F, \--no-file-manager*:: 87 | Disable browsing. 88 | 89 | *--terminal=PROGRAM*:: 90 | Set terminal command line to open mounted directories. Default is none! Pass an empty string to disable this feature. 91 | 92 | *--no-terminal*:: 93 | Disable terminal action. 94 | 95 | *--appindicator*:: 96 | Use AppIndicator3 for the status icon. Use this on Ubuntu/Unity if no icon is shown. 97 | 98 | *--no-appindicator*:: 99 | Use Gtk.StatusIcon for the status icon (default). 100 | 101 | *--password-cache MINUTES*:: 102 | Cache passwords for LUKS partitions and set the timeout. 103 | 104 | *--no-password-cache*:: 105 | Never cache passwords (default). 106 | 107 | *--event-hook COMMAND*:: 108 | Command to execute on device events. Command string be formatted using the event name and the list of device attributes (see below), e.g.: 109 | 110 | --event-hook "zenity --info --text '{event}: {device_presentation}'" 111 | 112 | *--no-event-hook*:: 113 | No notify command (default). 114 | 115 | 116 | Mount options 117 | ------------- 118 | *-a, \--all*:: 119 | Mount all handled devices. 120 | 121 | *-r, \--recursive*:: 122 | Recursively mount cleartext partitions after unlocking a LUKS device. This will happen by default when running the udiskie daemon. 123 | 124 | *-R, \--no-recursive*:: 125 | Disable recursive mounting (default). 126 | 127 | *-o OPTIONS, \--options=OPTIONS*:: 128 | Set mount options. 129 | 130 | 131 | Unmount options 132 | --------------- 133 | *-a, \--all*:: 134 | Unmount all handled devices. 135 | 136 | *-d, \--detach*:: 137 | Detach drive by e.g. powering down its physical port. 138 | 139 | *-D, \--no-detach*:: 140 | Don't detach drive (default). 141 | 142 | *-e, \--eject*:: 143 | Eject media from the drive, e.g CDROM. 144 | 145 | *-E, \--no-eject*:: 146 | Don't eject media (default). 147 | 148 | *-f, \--force*:: 149 | Force removal (recursive unmounting). 150 | 151 | *-F, \--no-force*:: 152 | Don't force removal (default). 153 | 154 | *-l, \--lock*:: 155 | Lock device after unmounting (default). 156 | 157 | *-L, \--no-lock*:: 158 | Don't lock device. 159 | 160 | 161 | Example Usage[[EU]] 162 | ------------------- 163 | Start *udiskie* in '~/.xinitrc': 164 | 165 | udiskie & 166 | 167 | Unmount media and power down USB device: 168 | 169 | udiskie-umount --detach /media/Sticky 170 | 171 | Mount all media: 172 | 173 | udiskie-mount -a 174 | 175 | Mount '/dev/sdb1': 176 | 177 | udiskie-mount /dev/sdb1 178 | 179 | 180 | Configuration 181 | ------------- 182 | The file '.config/udiskie/config.yml' can be used to configure defaults for command line parameters and customize further settings. The actual path may differ depending on '$XDG_CONFIG_HOME'. The file format is YAML, see https://en.wikipedia.org/wiki/YAML. If you don't want to install PyYAML, it is possible to use an equivalent JSON file with the name 'config.json' instead. 183 | 184 | ---------------------------------------------------------------------- 185 | # This is an example (nonsense) configuration file for udiskie. 186 | 187 | program_options: 188 | # Configure defaults for command line options 189 | 190 | tray: auto # [bool] Enable the tray icon. "auto" 191 | # means auto-hide the tray icon when 192 | # there are no handled devices. 193 | 194 | menu: flat # ["flat" | "nested"] Set the 195 | # systray menu behaviour. 196 | 197 | automount: false # [bool] Enable automatic mounting. 198 | 199 | notify: true # [bool] Enable notifications. 200 | 201 | password_cache: 30 # [int] Password cache in minutes. Caching is 202 | # disabled by default. It can be disabled 203 | # explicitly by setting it to false 204 | 205 | file_manager: xdg-open 206 | # [string] Set program to open directories. It will be invoked 207 | # with the folder path as its last command line argument. 208 | 209 | terminal: 'termite -d' 210 | # [string] Set terminal command line to open directories. It will be 211 | # invoked with the folder path as its last command line argument. 212 | 213 | password_prompt: ["gnome-keyring-query", "get", "{id_uuid}"] 214 | # [string|list] Set command to retrieve passwords. If specified 215 | # as a list it defines the ARGV array for the program call. If 216 | # specified as a string, it will be expanded in a shell-like 217 | # manner. Each string will be formatted using `str.format`. For a 218 | # list of device attributes, see below. The two special string values 219 | # "builtin:gui" and "builtin:tty" signify to use udiskie's 220 | # builtin password prompt. 221 | 222 | event_hook: "zenity --info --text '{event}: {device_presentation}'" 223 | # [string|list] Set command to be executed on any device event. 224 | # This is specified like `password_prompt`. 225 | 226 | device_config: 227 | 228 | # List of device option rules. Each item can match any combination of device 229 | # attributes. Additionally, it defines the resulting action (see below). 230 | # Any rule can contain multiple filters (AND) and multiple actions. 231 | # Only the first matching rule that defines a given action is used. 232 | # The rules defined here are simply prepended to the builtin device rules, 233 | # so that it is possible to completely overwrite the defaults by specifying 234 | # a catch-all rule (i.e. a rule without device attributes). 235 | # Filter rules can be passed a list of values, in which case the rule is matched 236 | # if the attribute matches any of the values in the list. 237 | 238 | - device_file: /dev/dm-5 # [filter] 239 | ignore: false # [action] never ignore this device 240 | - id_uuid: # [filter] match by device UUID 241 | - 9d53-13ba # This rule matches on either of these uids 242 | - 8675-309a 243 | options: [noexec, nodev] # [action] mount options can be given as list 244 | ignore: false # [action] never ignore this device (even if fs=FAT) 245 | automount: false # [action] do not automount this device 246 | - id_type: vfat # [filter] match file system type 247 | ignore: true # [action] ignore all FAT devices 248 | 249 | - id_type: ntfs # [filter] (optional) 250 | skip: true # [action] skip all further (even builtin) rules 251 | # for all matched devices, and resolve action result 252 | # on parent device 253 | 254 | - ignore: True # never mount/unmount or even show this in the GUI 255 | automount: False # show but do not automount this device 256 | options: [] # additional options to be passed when mounting 257 | 258 | mount_options: # [deprecated] do not use 259 | ignore_device: # [deprecated] do not use 260 | 261 | notifications: 262 | # Customize which notifications are shown for how long. Possible 263 | # values are: 264 | # positive number timeout in seconds 265 | # false disable 266 | # -1 use the libnotify default timeout 267 | 268 | timeout: 1.5 # set the default for all notifications 269 | 270 | # Specify only if you want to overwrite the default: 271 | device_mounted: 5 # mount notification 272 | device_unmounted: false # unmount notification 273 | device_added: false # device has appeared 274 | device_removed: false # device has disappeared 275 | device_unlocked: -1 # encrypted device was unlocked 276 | device_locked: -1 # encrypted device was locked 277 | job_failed: -1 # mount/unlock/.. has failed 278 | 279 | quickmenu_actions: [mount, unmount, unlock, terminal, detach, delete] 280 | # List of actions to be shown in the quickmenu or the special value 'all'. 281 | # The quickmenu is shown on left-click if using flat menu type. 282 | 283 | notification_actions: 284 | # Define which actions should be shown on notifications. Note that there 285 | # are currently only a limited set of actions available for each 286 | # notification. Events that are not explicitly specified show the default 287 | # set of actions. Specify an empty list if you don't want to see any 288 | # notification for the specified event: 289 | 290 | device_mounted: [browse] 291 | device_added: [mount] 292 | 293 | icon_names: 294 | # Customize the icon set used by the tray widget. Each entry 295 | # specifies a list of icon names. The first installed icon from 296 | # that list will be used. 297 | 298 | media: [drive-removable-media, media-optical] 299 | browse: [document-open, folder-open] 300 | terminal: [terminal, terminator, xfce-terminal] 301 | mount: [udiskie-mount] 302 | unmount: [udiskie-unmount] 303 | unlock: [udiskie-unlock] 304 | lock: [udiskie-lock] 305 | eject: [udiskie-eject, media-eject] 306 | detach: [udiskie-detach] 307 | delete: [udiskie-eject] 308 | quit: [application-exit] 309 | ---------------------------------------------------------------------- 310 | 311 | All keys are optional. Reasonable defaults are used if you leave them 312 | unspecified. 313 | 314 | 315 | Device attributes 316 | ----------------- 317 | 318 | Some of the config entries make use of Device attributes. The following list 319 | of attributes is currently available, but there is no guarantee that they will 320 | remain available: 321 | 322 | Attribute Hint/Example 323 | 324 | is_drive 325 | is_block 326 | is_partition_table 327 | is_partition 328 | is_filesystem 329 | is_luks 330 | is_loop 331 | is_toplevel 332 | is_detachable 333 | is_ejectable 334 | has_media 335 | device_file block device path, e.g. "/dev/sdb1" 336 | device_presentation display string, e.g. "/dev/sdb1" 337 | device_size block device size 338 | device_id unique, persistent device identifier 339 | id_usage E.g. "filesystem" or "crypto" 340 | is_crypto 341 | is_ignored 342 | id_type E.g. "ext4" or "crypto_LUKS" 343 | id_label device label 344 | id_uuid device UUID 345 | is_luks_cleartext 346 | is_external udisks flag HintSystem=false 347 | is_systeminternal udisks flag HintSystem=true 348 | is_mounted 349 | mount_paths list of mount paths 350 | mount_path any mount path 351 | is_unlocked 352 | in_use device or any of its children mounted 353 | should_automount 354 | ui_label 355 | loop_file file backing the loop device 356 | setup_by_uid user that setup the loop device 357 | autoclear automatically delete loop device after use 358 | symlinks 359 | drive_model 360 | drive_vendor 361 | drive_label 362 | ui_device_label 363 | ui_device_presentation 364 | ui_id_label 365 | ui_id_uuid 366 | 367 | 368 | See Also 369 | -------- 370 | linkman:udisks[1] 371 | 372 | https://www.freedesktop.org/wiki/Software/udisks/ 373 | 374 | 375 | Contact 376 | ------- 377 | You can use the github issues to report any issues you encounter, ask general questions or suggest new features. If you don't have or like github, you can contact me by email: 378 | 379 | https://github.com/coldfix/udiskie/issues 380 | 381 | thomas@coldfix.de 382 | -------------------------------------------------------------------------------- /example/config.yml: -------------------------------------------------------------------------------- 1 | # This is an example (nonsense) configuration file for udiskie. 2 | 3 | program_options: 4 | # Configure defaults for command line options 5 | 6 | tray: auto # [bool] Enable the tray icon. "auto" 7 | # means auto-hide the tray icon when 8 | # there are no handled devices. 9 | 10 | menu: flat # ["flat" | "nested"] Set the 11 | # systray menu behaviour. 12 | 13 | automount: false # [bool] Enable automatic mounting. 14 | 15 | notify: true # [bool] Enable notifications. 16 | 17 | password_cache: 30 # [int] Password cache in minutes. Caching is 18 | # disabled by default. It can be disabled 19 | # explicitly by setting it to false 20 | 21 | file_manager: xdg-open 22 | # [string] Set program to open directories. It will be invoked 23 | # with the folder path as its last command line argument. 24 | 25 | terminal: 'termite -d' 26 | # [string] Set terminal command line to open directories. It will be 27 | # invoked with the folder path as its last command line argument. 28 | 29 | password_prompt: ["gnome-keyring-query", "get", "{id_uuid}"] 30 | # [string|list] Set command to retrieve passwords. If specified 31 | # as a list it defines the ARGV array for the program call. If 32 | # specified as a string, it will be expanded in a shell-like 33 | # manner. Each string will be formatted using `str.format`. For a 34 | # list of device attributes, see below. The two special string values 35 | # "builtin:gui" and "builtin:tty" signify to use udiskie's 36 | # builtin password prompt. 37 | 38 | notify_command: "zenity --info --text '{event}: {device_presentation}'" 39 | # [string|list] Set command to be executed on any device event. 40 | # This is specified like `password_prompt`. 41 | 42 | device_config: 43 | 44 | # List of device option rules. Each item can match any combination of device 45 | # attributes. Additionally, it defines the resulting action (see below). 46 | # Any rule can contain multiple filters (AND) and multiple actions. 47 | # Only the first matching rule that defines a given action is used. 48 | # The rules defined here are simply prepended to the builtin device rules, 49 | # so that it is possible to completely overwrite the defaults by specifying 50 | # a catch-all rule (i.e. a rule without device attributes). 51 | 52 | - device_file: /dev/dm-5 # [filter] 53 | ignore: false # [action] never ignore this device 54 | - id_uuid: 9d53-13ba # [filter] match by device UUID 55 | options: [noexec, nodev] # [action] mount options can be given as list 56 | ignore: false # [action] never ignore this device (even if fs=FAT) 57 | automount: false # [action] do not automount this device 58 | - id_type: vfat # [filter] match file system type 59 | ignore: true # [action] ignore all FAT devices 60 | 61 | - id_type: ntfs # [filter] (optional) 62 | skip: true # [action] skip all further (even builtin) rules 63 | # for all matched devices, and resolve action result 64 | # on parent device 65 | 66 | - ignore: True # never mount/unmount or even show this in the GUI 67 | automount: False # show but do not automount this device 68 | options: [] # additional options to be passed when mounting 69 | 70 | mount_options: # [deprecated] do not use 71 | ignore_device: # [deprecated] do not use 72 | 73 | notifications: 74 | # Customize which notifications are shown for how long. Possible 75 | # values are: 76 | # positive number timeout in seconds 77 | # false disable 78 | # -1 use the libnotify default timeout 79 | 80 | timeout: 1.5 # set the default for all notifications 81 | 82 | # Specify only if you want to overwrite the default: 83 | device_mounted: 5 # mount notification 84 | device_unmounted: false # unmount notification 85 | device_added: false # device has appeared 86 | device_removed: false # device has disappeared 87 | device_unlocked: -1 # encrypted device was unlocked 88 | device_locked: -1 # encrypted device was locked 89 | job_failed: -1 # mount/unlock/.. has failed 90 | 91 | quickmenu_actions: [mount, unmount, unlock, terminal, detach, delete] 92 | # List of actions to be shown in the quickmenu or the special value 'all'. 93 | # The quickmenu is shown on left-click if using flat menu type. 94 | 95 | notification_actions: 96 | # Define which actions should be shown on notifications. Note that there 97 | # are currently only a limited set of actions available for each 98 | # notification. Events that are not explicitly specified show the default 99 | # set of actions. Specify an empty list if you don't want to see any 100 | # notification for the specified event: 101 | 102 | device_mounted: [browse] 103 | device_added: [mount] 104 | 105 | icon_names: 106 | # Customize the icon set used by the tray widget. Each entry 107 | # specifies a list of icon names. The first installed icon from 108 | # that list will be used. 109 | 110 | media: [drive-removable-media, media-optical] 111 | browse: [document-open, folder-open] 112 | terminal: [terminal, terminator, xfce-terminal] 113 | mount: [udiskie-mount] 114 | unmount: [udiskie-unmount] 115 | unlock: [udiskie-unlock] 116 | lock: [udiskie-lock] 117 | eject: [udiskie-eject, media-eject] 118 | detach: [udiskie-detach] 119 | delete: [udiskie-eject] 120 | quit: [application-exit] 121 | -------------------------------------------------------------------------------- /lang/Makefile: -------------------------------------------------------------------------------- 1 | version = $(shell sed -ne "s/__version__ = //p" ../udiskie/__init__.py) 2 | sources = find ../udiskie -name '*.py' 3 | echo = _() { echo "$$@"; "$$@"; }; _ 4 | 5 | all: po mo 6 | 7 | po: $(wildcard *.po) 8 | mo: $(addsuffix .mo, $(basename $(wildcard *.po))) 9 | @if [ -z "$$TEXTDOMAINDIR" ]; then\ 10 | echo In order to activate the newly built localization files, type:;\ 11 | echo;\ 12 | echo export TEXTDOMAINDIR=\"$$(readlink -f ../build/locale)\"; \ 13 | fi 14 | 15 | clean: 16 | rm -rf *.po~ *.mo locale 17 | 18 | %.mo: %.po 19 | msgfmt $< -o $@ 20 | @mkdir -p ../build/locale/$*/LC_MESSAGES/ 21 | @cp $@ ../build/locale/$*/LC_MESSAGES/udiskie.mo 22 | 23 | en_US.po: udiskie.pot 24 | msginit -o $@ -i $< -l en_US --no-translator 25 | 26 | %.po: udiskie.pot 27 | @if [ -f $@ ]; then \ 28 | $(echo) msgmerge -q -U $@ $< && touch $@;\ 29 | else \ 30 | $(echo) msginit -o $@ -i $< -l $*;\ 31 | fi 32 | 33 | udiskie.pot: $(shell $(sources)) 34 | $(sources) | sort | xgettext -o $@ -f - \ 35 | -LPython --from-code=UTF-8 \ 36 | --package-name=udiskie \ 37 | --package-version=$(version) \ 38 | --copyright-holder="Thomas Gläßle" 39 | sed -i $@ -e "s/YEAR/$$(date +%Y)/" 40 | @if [ $$( git diff --numstat -- $@ | cut -f1 ) -le 1 ]; then \ 41 | $(echo) git checkout -- $@; \ 42 | fi 43 | -------------------------------------------------------------------------------- /lang/report.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | count_strings() { 4 | grep '^msgid "' | tail -n +2 | wc -l 5 | } 6 | 7 | folder=$(dirname "$(readlink -f "$BASH_SOURCE")") 8 | n_total=$(msgattrib "$folder/udiskie.pot" | count_strings) 9 | 10 | echo -e "File\tUntranslated\tTranslated\tFuzzy\tObsolete\t%" 11 | for po in "$folder"/*.po; do 12 | n_u=$(msgattrib $po --untranslated | count_strings) 13 | n_t=$(msgattrib $po --translated | count_strings) 14 | n_f=$(msgattrib $po --fuzzy | count_strings) 15 | n_o=$(msgattrib $po --obsolete | count_strings) 16 | percent=$( echo "scale=0; ($n_total-$n_u)*100/$n_total" | bc ) 17 | 18 | echo -e "$(basename "$po")\t$n_u\t$n_t\t$n_f\t$n_o\t$percent" 19 | done 20 | -------------------------------------------------------------------------------- /lang/udiskie.pot: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) 2024 Thomas Gläßle 3 | # This file is distributed under the same license as the udiskie package. 4 | # FIRST AUTHOR , 2024. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: udiskie 2.5.6\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2024-12-11 12:30+0100\n" 12 | "PO-Revision-Date: 2024-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: ../udiskie/cli.py:46 21 | #, python-brace-format 22 | msgid "These options are mutually exclusive: {0}" 23 | msgstr "" 24 | 25 | #: ../udiskie/cli.py:119 26 | msgid "" 27 | "\n" 28 | " Note, that the options in the individual groups are mutually exclusive.\n" 29 | "\n" 30 | " The config file can be a JSON or preferably a YAML file. For an\n" 31 | " example, see the MAN page (or doc/udiskie.8.txt in the repository).\n" 32 | " " 33 | msgstr "" 34 | 35 | #: ../udiskie/cli.py:139 36 | #, python-format 37 | msgid "%(message)s" 38 | msgstr "" 39 | 40 | #: ../udiskie/cli.py:141 41 | #, python-format 42 | msgid "%(levelname)s [%(asctime)s] %(name)s: %(message)s" 43 | msgstr "" 44 | 45 | #: ../udiskie/cli.py:386 46 | msgid "" 47 | "Typelib for 'libnotify' is not available. Possible causes include:\n" 48 | "\t- libnotify is not installed\n" 49 | "\t- the typelib is provided by a separate package\n" 50 | "\t- libnotify was built with introspection disabled\n" 51 | "\n" 52 | "Starting udiskie without notifications." 53 | msgstr "" 54 | 55 | #: ../udiskie/cli.py:400 56 | msgid "" 57 | "Not run within X or Wayland session.\n" 58 | "Starting udiskie without tray icon.\n" 59 | msgstr "" 60 | 61 | #: ../udiskie/cli.py:407 62 | msgid "" 63 | "Typelib for 'Gtk 3.0' is not available. Possible causes include:\n" 64 | "\t- GTK3 is not installed\n" 65 | "\t- the typelib is provided by a separate package\n" 66 | "\t- GTK3 was built with introspection disabled\n" 67 | "Starting udiskie without tray icon.\n" 68 | msgstr "" 69 | 70 | #: ../udiskie/cli.py:417 71 | msgid "" 72 | "Typelib for 'AppIndicator3 0.1' is not available. Possible causes include:\n" 73 | "\t- libappindicator is not installed\n" 74 | "\t- the typelib is provided by a separate package\n" 75 | "\t- it was built with introspection disabled\n" 76 | "Starting udiskie without appindicator icon.\n" 77 | msgstr "" 78 | 79 | #: ../udiskie/cli.py:436 80 | msgid "" 81 | "The 'notify_command' option was renamed to 'event_hook'. The old name still " 82 | "works, but may be removed in a future version. Please change your command " 83 | "line and config to use the new name." 84 | msgstr "" 85 | 86 | #: ../udiskie/cli.py:443 87 | msgid "Ignoring 'notify_command' in favor of 'event_hook'." 88 | msgstr "" 89 | 90 | #: ../udiskie/config.py:131 91 | msgid "Unknown matching attribute: {!r}" 92 | msgstr "" 93 | 94 | #: ../udiskie/config.py:133 95 | #, python-brace-format 96 | msgid "new rule: {0}" 97 | msgstr "" 98 | 99 | #: ../udiskie/config.py:136 100 | #, python-brace-format 101 | msgid "{0} -> {1}" 102 | msgstr "" 103 | 104 | #: ../udiskie/config.py:155 105 | #, python-brace-format 106 | msgid "{0} matched {1}" 107 | msgstr "" 108 | 109 | #: ../udiskie/config.py:232 110 | #, python-brace-format 111 | msgid "Failed to read config file: {0}" 112 | msgstr "" 113 | 114 | #: ../udiskie/config.py:235 115 | msgid "Failed to read {0!r}: {1}" 116 | msgstr "" 117 | 118 | #: ../udiskie/depend.py:59 119 | msgid "" 120 | "Missing runtime dependency GTK 3. Falling back to GTK 2 for password prompt" 121 | msgstr "" 122 | 123 | #: ../udiskie/depend.py:65 124 | msgid "X server not connected!" 125 | msgstr "" 126 | 127 | #: ../udiskie/mount.py:29 128 | #, python-brace-format 129 | msgid "failed to {0} {1}: {2}" 130 | msgstr "" 131 | 132 | #: ../udiskie/mount.py:117 133 | #, python-brace-format 134 | msgid "not browsing {0}: not mounted" 135 | msgstr "" 136 | 137 | #: ../udiskie/mount.py:120 138 | #, python-brace-format 139 | msgid "not browsing {0}: no program" 140 | msgstr "" 141 | 142 | #: ../udiskie/mount.py:122 ../udiskie/mount.py:142 143 | #, python-brace-format 144 | msgid "opening {0} on {0.mount_paths[0]}" 145 | msgstr "" 146 | 147 | #: ../udiskie/mount.py:124 ../udiskie/mount.py:144 148 | #, python-brace-format 149 | msgid "opened {0} on {0.mount_paths[0]}" 150 | msgstr "" 151 | 152 | #: ../udiskie/mount.py:137 153 | #, python-brace-format 154 | msgid "not opening terminal {0}: not mounted" 155 | msgstr "" 156 | 157 | #: ../udiskie/mount.py:140 158 | #, python-brace-format 159 | msgid "not opening terminal {0}: no program" 160 | msgstr "" 161 | 162 | #: ../udiskie/mount.py:158 163 | #, python-brace-format 164 | msgid "not mounting {0}: unhandled device" 165 | msgstr "" 166 | 167 | #: ../udiskie/mount.py:161 168 | #, python-brace-format 169 | msgid "not mounting {0}: already mounted" 170 | msgstr "" 171 | 172 | #: ../udiskie/mount.py:165 173 | #, python-brace-format 174 | msgid "mounting {0} with {1}" 175 | msgstr "" 176 | 177 | #: ../udiskie/mount.py:168 178 | #, python-brace-format 179 | msgid "mounted {0} on {1}" 180 | msgstr "" 181 | 182 | #: ../udiskie/mount.py:174 183 | msgid "" 184 | "Mounting NTFS device with default driver.\n" 185 | "Please install 'ntfs-3g' if you experience problems or the device is " 186 | "readonly." 187 | msgstr "" 188 | 189 | #: ../udiskie/mount.py:188 190 | #, python-brace-format 191 | msgid "not unmounting {0}: unhandled device" 192 | msgstr "" 193 | 194 | #: ../udiskie/mount.py:191 195 | #, python-brace-format 196 | msgid "not unmounting {0}: not mounted" 197 | msgstr "" 198 | 199 | #: ../udiskie/mount.py:193 200 | #, python-brace-format 201 | msgid "unmounting {0}" 202 | msgstr "" 203 | 204 | #: ../udiskie/mount.py:195 205 | #, python-brace-format 206 | msgid "unmounted {0}" 207 | msgstr "" 208 | 209 | #: ../udiskie/mount.py:209 210 | #, python-brace-format 211 | msgid "not unlocking {0}: unhandled device" 212 | msgstr "" 213 | 214 | #: ../udiskie/mount.py:212 215 | #, python-brace-format 216 | msgid "not unlocking {0}: already unlocked" 217 | msgstr "" 218 | 219 | #: ../udiskie/mount.py:215 220 | #, python-brace-format 221 | msgid "not unlocking {0}: no password prompt" 222 | msgstr "" 223 | 224 | #: ../udiskie/mount.py:229 225 | #, python-brace-format 226 | msgid "not unlocking {0}: cancelled by user" 227 | msgstr "" 228 | 229 | #: ../udiskie/mount.py:234 230 | #, python-brace-format 231 | msgid "unlocking {0} using keyfile" 232 | msgstr "" 233 | 234 | #: ../udiskie/mount.py:237 235 | #, python-brace-format 236 | msgid "unlocking {0}" 237 | msgstr "" 238 | 239 | #: ../udiskie/mount.py:240 240 | #, python-brace-format 241 | msgid "unlocked {0}" 242 | msgstr "" 243 | 244 | #: ../udiskie/mount.py:249 245 | #, python-brace-format 246 | msgid "no cached key for {0}" 247 | msgstr "" 248 | 249 | #: ../udiskie/mount.py:251 250 | #, python-brace-format 251 | msgid "unlocking {0} using cached password" 252 | msgstr "" 253 | 254 | #: ../udiskie/mount.py:255 255 | #, python-brace-format 256 | msgid "failed to unlock {0} using cached password" 257 | msgstr "" 258 | 259 | #: ../udiskie/mount.py:258 260 | #, python-brace-format 261 | msgid "unlocked {0} using cached password" 262 | msgstr "" 263 | 264 | #: ../udiskie/mount.py:266 265 | msgid "No matching keyfile rule for {}." 266 | msgstr "" 267 | 268 | #: ../udiskie/mount.py:272 269 | #, python-brace-format 270 | msgid "keyfile for {0} not found: {1}" 271 | msgstr "" 272 | 273 | #: ../udiskie/mount.py:274 274 | #, python-brace-format 275 | msgid "unlocking {0} using keyfile {1}" 276 | msgstr "" 277 | 278 | #: ../udiskie/mount.py:278 279 | #, python-brace-format 280 | msgid "failed to unlock {0} using keyfile" 281 | msgstr "" 282 | 283 | #: ../udiskie/mount.py:281 284 | #, python-brace-format 285 | msgid "unlocked {0} using keyfile" 286 | msgstr "" 287 | 288 | #: ../udiskie/mount.py:307 289 | #, python-brace-format 290 | msgid "not locking {0}: unhandled device" 291 | msgstr "" 292 | 293 | #: ../udiskie/mount.py:310 294 | #, python-brace-format 295 | msgid "not locking {0}: not unlocked" 296 | msgstr "" 297 | 298 | #: ../udiskie/mount.py:312 299 | #, python-brace-format 300 | msgid "locking {0}" 301 | msgstr "" 302 | 303 | #: ../udiskie/mount.py:314 304 | #, python-brace-format 305 | msgid "locked {0}" 306 | msgstr "" 307 | 308 | #: ../udiskie/mount.py:351 ../udiskie/mount.py:394 309 | #, python-brace-format 310 | msgid "not adding {0}: unhandled device" 311 | msgstr "" 312 | 313 | #: ../udiskie/mount.py:430 ../udiskie/mount.py:480 314 | #, python-brace-format 315 | msgid "not removing {0}: unhandled device" 316 | msgstr "" 317 | 318 | #: ../udiskie/mount.py:505 319 | #, python-brace-format 320 | msgid "not ejecting {0}: unhandled device" 321 | msgstr "" 322 | 323 | #: ../udiskie/mount.py:509 324 | #, python-brace-format 325 | msgid "not ejecting {0}: drive not ejectable" 326 | msgstr "" 327 | 328 | #: ../udiskie/mount.py:515 329 | #, python-brace-format 330 | msgid "ejecting {0}" 331 | msgstr "" 332 | 333 | #: ../udiskie/mount.py:517 334 | #, python-brace-format 335 | msgid "ejected {0}" 336 | msgstr "" 337 | 338 | #: ../udiskie/mount.py:531 339 | #, python-brace-format 340 | msgid "not detaching {0}: unhandled device" 341 | msgstr "" 342 | 343 | #: ../udiskie/mount.py:535 344 | #, python-brace-format 345 | msgid "not detaching {0}: drive not detachable" 346 | msgstr "" 347 | 348 | #: ../udiskie/mount.py:539 349 | #, python-brace-format 350 | msgid "detaching {0}" 351 | msgstr "" 352 | 353 | #: ../udiskie/mount.py:544 354 | #, python-brace-format 355 | msgid "detached {0}" 356 | msgstr "" 357 | 358 | #: ../udiskie/mount.py:595 359 | #, python-brace-format 360 | msgid "not setting up {0}: already up" 361 | msgstr "" 362 | 363 | #: ../udiskie/mount.py:598 364 | #, python-brace-format 365 | msgid "not setting up {0}: not a file" 366 | msgstr "" 367 | 368 | #: ../udiskie/mount.py:600 369 | #, python-brace-format 370 | msgid "setting up loop device {0}" 371 | msgstr "" 372 | 373 | #: ../udiskie/mount.py:618 374 | #, python-brace-format 375 | msgid "" 376 | "Insufficient permission to open {0} in read-write mode. Retrying in read-" 377 | "only mode." 378 | msgstr "" 379 | 380 | #: ../udiskie/mount.py:630 381 | #, python-brace-format 382 | msgid "set up {0} as {1}" 383 | msgstr "" 384 | 385 | #: ../udiskie/mount.py:645 386 | #, python-brace-format 387 | msgid "not deleting {0}: unhandled device" 388 | msgstr "" 389 | 390 | #: ../udiskie/mount.py:649 391 | #, python-brace-format 392 | msgid "deleting {0}" 393 | msgstr "" 394 | 395 | #: ../udiskie/mount.py:651 396 | #, python-brace-format 397 | msgid "deleted {0}" 398 | msgstr "" 399 | 400 | #: ../udiskie/mount.py:777 401 | #, python-brace-format 402 | msgid "Browse {0}" 403 | msgstr "" 404 | 405 | #: ../udiskie/mount.py:778 406 | #, python-brace-format 407 | msgid "Hack on {0}" 408 | msgstr "" 409 | 410 | #: ../udiskie/mount.py:779 411 | #, python-brace-format 412 | msgid "Mount {0}" 413 | msgstr "" 414 | 415 | #: ../udiskie/mount.py:780 416 | #, python-brace-format 417 | msgid "Unmount {0}" 418 | msgstr "" 419 | 420 | #: ../udiskie/mount.py:781 421 | #, python-brace-format 422 | msgid "Unlock {0}" 423 | msgstr "" 424 | 425 | #: ../udiskie/mount.py:782 426 | #, python-brace-format 427 | msgid "Lock {0}" 428 | msgstr "" 429 | 430 | #: ../udiskie/mount.py:783 431 | #, python-brace-format 432 | msgid "Eject {1}" 433 | msgstr "" 434 | 435 | #: ../udiskie/mount.py:784 436 | #, python-brace-format 437 | msgid "Unpower {1}" 438 | msgstr "" 439 | 440 | #: ../udiskie/mount.py:785 441 | #, python-brace-format 442 | msgid "Clear password for {0}" 443 | msgstr "" 444 | 445 | #: ../udiskie/mount.py:786 446 | #, python-brace-format 447 | msgid "Detach {0}" 448 | msgstr "" 449 | 450 | #: ../udiskie/notify.py:62 451 | msgid "Browse directory" 452 | msgstr "" 453 | 454 | #: ../udiskie/notify.py:64 455 | msgid "Open terminal" 456 | msgstr "" 457 | 458 | #: ../udiskie/notify.py:68 459 | msgid "Device mounted" 460 | msgstr "" 461 | 462 | #: ../udiskie/notify.py:69 463 | #, python-brace-format 464 | msgid "{0.ui_label} mounted on {0.mount_paths[0]}" 465 | msgstr "" 466 | 467 | #: ../udiskie/notify.py:80 468 | msgid "Device unmounted" 469 | msgstr "" 470 | 471 | #: ../udiskie/notify.py:81 472 | #, python-brace-format 473 | msgid "{0.ui_label} unmounted" 474 | msgstr "" 475 | 476 | #: ../udiskie/notify.py:90 477 | msgid "Device locked" 478 | msgstr "" 479 | 480 | #: ../udiskie/notify.py:91 481 | #, python-brace-format 482 | msgid "{0.device_presentation} locked" 483 | msgstr "" 484 | 485 | #: ../udiskie/notify.py:100 486 | msgid "Device unlocked" 487 | msgstr "" 488 | 489 | #: ../udiskie/notify.py:101 490 | #, python-brace-format 491 | msgid "{0.device_presentation} unlocked" 492 | msgstr "" 493 | 494 | #: ../udiskie/notify.py:135 495 | msgid "Device added" 496 | msgstr "" 497 | 498 | #: ../udiskie/notify.py:136 499 | #, python-brace-format 500 | msgid "device appeared on {0.device_presentation}" 501 | msgstr "" 502 | 503 | #: ../udiskie/notify.py:155 504 | msgid "Device removed" 505 | msgstr "" 506 | 507 | #: ../udiskie/notify.py:156 508 | #, python-brace-format 509 | msgid "device disappeared on {0.device_presentation}" 510 | msgstr "" 511 | 512 | #: ../udiskie/notify.py:165 513 | #, python-brace-format 514 | msgid "" 515 | "failed to {0} {1}:\n" 516 | "{2}" 517 | msgstr "" 518 | 519 | #: ../udiskie/notify.py:167 520 | #, python-brace-format 521 | msgid "failed to {0} device {1}." 522 | msgstr "" 523 | 524 | #: ../udiskie/notify.py:173 525 | msgid "Retry" 526 | msgstr "" 527 | 528 | #: ../udiskie/notify.py:176 529 | msgid "Job failed" 530 | msgstr "" 531 | 532 | #: ../udiskie/notify.py:207 533 | #, python-brace-format 534 | msgid "Failed to show notification: {0}" 535 | msgstr "" 536 | 537 | #: ../udiskie/prompt.py:96 538 | msgid "Show password" 539 | msgstr "" 540 | 541 | #: ../udiskie/prompt.py:101 542 | msgid "Open keyfile…" 543 | msgstr "" 544 | 545 | #: ../udiskie/prompt.py:108 546 | msgid "Cache password" 547 | msgstr "" 548 | 549 | #: ../udiskie/prompt.py:123 550 | msgid "Open a keyfile to unlock the LUKS device" 551 | msgstr "" 552 | 553 | #: ../udiskie/prompt.py:157 ../udiskie/prompt.py:167 554 | #, python-brace-format 555 | msgid "Enter password for {0.device_presentation}: " 556 | msgstr "" 557 | 558 | #: ../udiskie/prompt.py:203 559 | msgid "Unknown device attribute {!r} in format string: {!r}" 560 | msgstr "" 561 | 562 | #: ../udiskie/prompt.py:255 563 | msgid "" 564 | "Can't find file browser: {0!r}. You may want to change the value for the '-" 565 | "f' option." 566 | msgstr "" 567 | 568 | #: ../udiskie/tray.py:182 569 | msgid "Managed devices" 570 | msgstr "" 571 | 572 | #: ../udiskie/tray.py:198 573 | msgid "Mount disc image" 574 | msgstr "" 575 | 576 | #: ../udiskie/tray.py:204 577 | msgid "Enable automounting" 578 | msgstr "" 579 | 580 | #: ../udiskie/tray.py:210 581 | msgid "Enable notifications" 582 | msgstr "" 583 | 584 | #: ../udiskie/tray.py:219 585 | msgid "Quit" 586 | msgstr "" 587 | 588 | #: ../udiskie/tray.py:226 589 | msgid "Open disc image" 590 | msgstr "" 591 | 592 | #: ../udiskie/tray.py:228 593 | msgid "Open" 594 | msgstr "" 595 | 596 | #: ../udiskie/tray.py:229 597 | msgid "Cancel" 598 | msgstr "" 599 | 600 | #: ../udiskie/tray.py:269 601 | msgid "Invalid node!" 602 | msgstr "" 603 | 604 | #: ../udiskie/tray.py:271 605 | msgid "No external devices" 606 | msgstr "" 607 | 608 | #: ../udiskie/tray.py:387 609 | msgid "udiskie" 610 | msgstr "" 611 | 612 | #: ../udiskie/udisks2.py:661 613 | #, python-brace-format 614 | msgid "found device owning \"{0}\": \"{1}\"" 615 | msgstr "" 616 | 617 | #: ../udiskie/udisks2.py:664 618 | #, python-brace-format 619 | msgid "no device found owning \"{0}\"" 620 | msgstr "" 621 | 622 | #: ../udiskie/udisks2.py:683 623 | #, python-brace-format 624 | msgid "Daemon version: {0}" 625 | msgstr "" 626 | 627 | #: ../udiskie/udisks2.py:688 628 | #, python-brace-format 629 | msgid "Keyfile support: {0}" 630 | msgstr "" 631 | 632 | #: ../udiskie/udisks2.py:767 633 | #, python-brace-format 634 | msgid "+++ {0}: {1}" 635 | msgstr "" 636 | -------------------------------------------------------------------------------- /lang/zh_CN.po: -------------------------------------------------------------------------------- 1 | 2 | # English translations for udiskie package. 3 | # Copyright (C) 2024 Thomas Gläßle 4 | # This file is distributed under the same license as the udiskie package. 5 | # Automatically generated, 2024. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: udiskie 2.5.3\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2024-09-04 05:17+0000\n" 12 | "PO-Revision-Date: 2024-09-04 05:17+0000\n" 13 | "Last-Translator: Automatically generated\n" 14 | "Language-Team: Chinese\n" 15 | "Language: zh_CN\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: ../udiskie/cli.py:46 22 | #, python-brace-format 23 | msgid "These options are mutually exclusive: {0}" 24 | msgstr "这些选项是互斥的: {0}" 25 | 26 | #: ../udiskie/cli.py:119 27 | msgid "" 28 | "\n" 29 | " Note, that the options in the individual groups are mutually exclusive.\n" 30 | "\n" 31 | " The config file can be a JSON or preferably a YAML file. For an\n" 32 | " example, see the MAN page (or doc/udiskie.8.txt in the repository).\n" 33 | " " 34 | msgstr "" 35 | "\n" 36 | " 请注意,各组中的选项是互斥的。\n" 37 | "\n" 38 | " 配置文件可以是 JSON 文件,最好是 YAML 文件。\n" 39 | " 例如,请参阅 MAN 页面(或软件源中的 doc/udiskie.8.txt)。\n" 40 | " " 41 | 42 | #: ../udiskie/cli.py:139 43 | #, python-format 44 | msgid "%(message)s" 45 | msgstr "%(信息)s" 46 | 47 | #: ../udiskie/cli.py:141 48 | #, python-format 49 | msgid "%(levelname)s [%(asctime)s] %(name)s: %(message)s" 50 | msgstr "%(日志输出级别)s [%(时间与日期)s] %(变量名)s: %(消息)s" 51 | 52 | #: ../udiskie/cli.py:386 53 | msgid "" 54 | "Typelib for 'libnotify' is not available. Possible causes include:\n" 55 | "\t- libnotify is not installed\n" 56 | "\t- the typelib is provided by a separate package\n" 57 | "\t- libnotify was built with introspection disabled\n" 58 | "\n" 59 | "Starting udiskie without notifications." 60 | msgstr "" 61 | "libnotify' 的类型库(Typelib)不可用。可能的原因包括:\n" 62 | "\t- 没有安装libnotify\n" 63 | "\t- 类型库(typelib)由一个单独的软件包提供\n" 64 | "\t- 编译libnotify时禁用了对象自省\n" 65 | "\n" 66 | "启动不带通知功能的udiskie。" 67 | 68 | #: ../udiskie/cli.py:400 69 | msgid "" 70 | "Not run within X or Wayland session.\n" 71 | "Starting udiskie without tray icon.\n" 72 | msgstr "" 73 | "没有运行在X或者wayland下.\n" 74 | "启动不带托盘图标的udiskie。\n" 75 | 76 | #: ../udiskie/cli.py:407 77 | msgid "" 78 | "Typelib for 'Gtk 3.0' is not available. Possible causes include:\n" 79 | "\t- GTK3 is not installed\n" 80 | "\t- the typelib is provided by a separate package\n" 81 | "\t- GTK3 was built with introspection disabled\n" 82 | "Starting udiskie without tray icon.\n" 83 | msgstr "" 84 | "Gtk 3.0 的类型库(Typelib)不可用。可能的原因包括:\n" 85 | "\t- 没有安装GIK3\n" 86 | "\t- 类型库(typelib)由一个单独的软件包提供\n" 87 | "\t- 编译GTK3时禁用了对象自省\n" 88 | "启动不带托盘图标的udiskie。\n" 89 | 90 | #: ../udiskie/cli.py:417 91 | msgid "" 92 | "Typelib for 'AppIndicator3 0.1' is not available. Possible causes include:\n" 93 | "\t- libappindicator is not installed\n" 94 | "\t- the typelib is provided by a separate package\n" 95 | "\t- it was built with introspection disabled\n" 96 | "Starting udiskie without appindicator icon.\n" 97 | msgstr "" 98 | "AppIndicator3 0.1 的类型库(typelib)不可用,可能的原因包括:\n" 99 | "\t- 没有安装libappindicator\n" 100 | "\t- 类型库(typelib)由一个单独的软件包提供\n" 101 | "\t- 在编译时禁用了对象自省功能\n" 102 | "启动不带托盘图标的udiskie。\n" 103 | 104 | #: ../udiskie/cli.py:436 105 | msgid "" 106 | "The 'notify_command' option was renamed to 'event_hook'. The old name still " 107 | "works, but may be removed in a future version. Please change your command " 108 | "line and config to use the new name." 109 | msgstr "" 110 | " notify_command “选项更名为 'event_hook'。旧名称仍" 111 | "可以运行,但可能会在未来版本中删除。请更改您的命令" 112 | "行并配置用新的命令名。" 113 | 114 | #: ../udiskie/cli.py:443 115 | msgid "Ignoring 'notify_command' in favor of 'event_hook'." 116 | msgstr "忽略 “notify_command ”而使用 “event_hook”。" 117 | 118 | #: ../udiskie/config.py:131 119 | msgid "Unknown matching attribute: {!r}" 120 | msgstr "未知匹配属性:{!r}" 121 | 122 | #: ../udiskie/config.py:133 123 | #, python-brace-format 124 | msgid "new rule: {0}" 125 | msgstr "新规则: {0}" 126 | 127 | #: ../udiskie/config.py:136 128 | #, python-brace-format 129 | msgid "{0} -> {1}" 130 | msgstr "{0} -> {1}" 131 | 132 | #: ../udiskie/config.py:155 133 | #, python-brace-format 134 | msgid "{0} matched {1}" 135 | msgstr "{0} 匹配 {1}" 136 | 137 | #: ../udiskie/config.py:232 138 | #, python-brace-format 139 | msgid "Failed to read config file: {0}" 140 | msgstr "读取配置文件失败: {0}" 141 | 142 | #: ../udiskie/config.py:235 143 | msgid "Failed to read {0!r}: {1}" 144 | msgstr "读取{0!r}: {1}失败" 145 | 146 | #: ../udiskie/depend.py:59 147 | msgid "" 148 | "Missing runtime dependency GTK 3. Falling back to GTK 2 for password prompt" 149 | msgstr "" 150 | "缺少GTK 3运行时依赖。密码提示回退到GTK 2" 151 | 152 | #: ../udiskie/depend.py:65 153 | msgid "X server not connected!" 154 | msgstr "X服务器未连接!" 155 | 156 | #: ../udiskie/mount.py:29 157 | #, python-brace-format 158 | msgid "failed to {0} {1}: {2}" 159 | msgstr "不能到 {0} {1}: {2}" 160 | 161 | #: ../udiskie/mount.py:117 162 | #, python-brace-format 163 | msgid "not browsing {0}: not mounted" 164 | msgstr "未浏览 {0}:未挂载" 165 | 166 | #: ../udiskie/mount.py:120 167 | #, python-brace-format 168 | msgid "not browsing {0}: no program" 169 | msgstr "未浏览 {0}:未计划" 170 | 171 | #: ../udiskie/mount.py:122 ../udiskie/mount.py:142 172 | #, python-brace-format 173 | msgid "opening {0} on {0.mount_paths[0]}" 174 | msgstr "开启{0}在位置{0.mount_paths[0]}" 175 | 176 | #: ../udiskie/mount.py:124 ../udiskie/mount.py:144 177 | #, python-brace-format 178 | msgid "opened {0} on {0.mount_paths[0]}" 179 | msgstr "已开启{0}在位置{0.mount_paths[0]}" 180 | 181 | #: ../udiskie/mount.py:137 182 | #, python-brace-format 183 | msgid "not opening terminal {0}: not mounted" 184 | msgstr "未打开终端 {0}: 未挂载" 185 | 186 | #: ../udiskie/mount.py:140 187 | #, python-brace-format 188 | msgid "not opening terminal {0}: no program" 189 | msgstr "未打开终端 {0}: 未计划" 190 | 191 | #: ../udiskie/mount.py:158 192 | #, python-brace-format 193 | msgid "not mounting {0}: unhandled device" 194 | msgstr "未挂载 {0}: 未处理设备" 195 | 196 | #: ../udiskie/mount.py:161 197 | #, python-brace-format 198 | msgid "not mounting {0}: already mounted" 199 | msgstr "未挂载 {0}:已被挂载" 200 | 201 | #: ../udiskie/mount.py:165 202 | #, python-brace-format 203 | msgid "mounting {0} with {1}" 204 | msgstr "将 {0} 挂载到 {1} 上" 205 | 206 | #: ../udiskie/mount.py:168 207 | #, python-brace-format 208 | msgid "mounted {0} on {1}" 209 | msgstr "已挂载 {0} 在 {1} 上" 210 | 211 | #: ../udiskie/mount.py:174 212 | msgid "" 213 | "Mounting NTFS device with default driver.\n" 214 | "Please install 'ntfs-3g' if you experience problems or the device is " 215 | "readonly." 216 | msgstr "" 217 | "使用默认驱动程序挂载 NTFS 设备。\n" 218 | " 请安装 “ntfs-3g”,如果遇到问题或设备是" 219 | "只读的." 220 | 221 | #: ../udiskie/mount.py:188 222 | #, python-brace-format 223 | msgid "not unmounting {0}: unhandled device" 224 | msgstr "未挂载 {0}: 未处理设备" 225 | 226 | #: ../udiskie/mount.py:191 227 | #, python-brace-format 228 | msgid "not unmounting {0}: not mounted" 229 | msgstr "未卸载 {0}:未挂载" 230 | 231 | #: ../udiskie/mount.py:193 232 | #, python-brace-format 233 | msgid "unmounting {0}" 234 | msgstr "卸载 {0}" 235 | 236 | #: ../udiskie/mount.py:195 237 | #, python-brace-format 238 | msgid "unmounted {0}" 239 | msgstr "未挂载 {0}" 240 | 241 | #: ../udiskie/mount.py:209 242 | #, python-brace-format 243 | msgid "not unlocking {0}: unhandled device" 244 | msgstr "无法解锁 {0}:未处理设备" 245 | 246 | #: ../udiskie/mount.py:212 247 | #, python-brace-format 248 | msgid "not unlocking {0}: already unlocked" 249 | msgstr "未解锁 {0}:已解锁" 250 | 251 | #: ../udiskie/mount.py:215 252 | #, python-brace-format 253 | msgid "not unlocking {0}: no password prompt" 254 | msgstr "未解锁 {0}:无密码提示" 255 | 256 | #: ../udiskie/mount.py:229 257 | #, python-brace-format 258 | msgid "not unlocking {0}: cancelled by user" 259 | msgstr "未解锁 {0}:用户取消" 260 | 261 | #: ../udiskie/mount.py:234 262 | #, python-brace-format 263 | msgid "unlocking {0} using keyfile" 264 | msgstr "使用密钥文件解锁 {0}" 265 | 266 | #: ../udiskie/mount.py:237 267 | #, python-brace-format 268 | msgid "unlocking {0}" 269 | msgstr "解锁 {0}" 270 | 271 | #: ../udiskie/mount.py:240 272 | #, python-brace-format 273 | msgid "unlocked {0}" 274 | msgstr "已解锁 {0}" 275 | 276 | #: ../udiskie/mount.py:249 277 | #, python-brace-format 278 | msgid "no cached key for {0}" 279 | msgstr "{0}没有可用的缓存密钥" 280 | 281 | #: ../udiskie/mount.py:251 282 | #, python-brace-format 283 | msgid "unlocking {0} using cached password" 284 | msgstr "使用缓存密码解锁 {0}" 285 | 286 | #: ../udiskie/mount.py:255 287 | #, python-brace-format 288 | msgid "failed to unlock {0} using cached password" 289 | msgstr "使用缓存密码解锁 {0} 失败" 290 | 291 | #: ../udiskie/mount.py:258 292 | #, python-brace-format 293 | msgid "unlocked {0} using cached password" 294 | msgstr "用缓存的密码解锁了 {0}" 295 | 296 | #: ../udiskie/mount.py:266 297 | msgid "No matching keyfile rule for {}." 298 | msgstr "没有与 {} 匹配的关键文件规则。" 299 | 300 | #: ../udiskie/mount.py:272 301 | #, python-brace-format 302 | msgid "keyfile for {0} not found: {1}" 303 | msgstr "未找到 {0} 的密钥文件: {1}" 304 | 305 | #: ../udiskie/mount.py:274 306 | #, python-brace-format 307 | msgid "unlocking {0} using keyfile {1}" 308 | msgstr "用 {1} 密钥文件解锁 {0}" 309 | 310 | #: ../udiskie/mount.py:278 311 | #, python-brace-format 312 | msgid "failed to unlock {0} using keyfile" 313 | msgstr "用密码文件解锁 {0} 失败" 314 | 315 | #: ../udiskie/mount.py:281 316 | #, python-brace-format 317 | msgid "unlocked {0} using keyfile" 318 | msgstr "已用密钥文件解锁 {0}" 319 | 320 | #: ../udiskie/mount.py:307 321 | #, python-brace-format 322 | msgid "not locking {0}: unhandled device" 323 | msgstr "未锁定 {0}:未处理设备" 324 | 325 | #: ../udiskie/mount.py:310 326 | #, python-brace-format 327 | msgid "not locking {0}: not unlocked" 328 | msgstr "未锁定 {0}:未解锁" 329 | 330 | #: ../udiskie/mount.py:312 331 | #, python-brace-format 332 | msgid "locking {0}" 333 | msgstr "锁定{0}" 334 | 335 | #: ../udiskie/mount.py:314 336 | #, python-brace-format 337 | msgid "locked {0}" 338 | msgstr "已锁定 {0}" 339 | 340 | #: ../udiskie/mount.py:351 ../udiskie/mount.py:394 341 | #, python-brace-format 342 | msgid "not adding {0}: unhandled device" 343 | msgstr "未添加 {0}:未处理设备" 344 | 345 | #: ../udiskie/mount.py:430 ../udiskie/mount.py:480 346 | #, python-brace-format 347 | msgid "not removing {0}: unhandled device" 348 | msgstr "未移除 {0}:未处理设备" 349 | 350 | #: ../udiskie/mount.py:505 351 | #, python-brace-format 352 | msgid "not ejecting {0}: unhandled device" 353 | msgstr "无法弹出 {0}:未处理设备" 354 | 355 | #: ../udiskie/mount.py:509 356 | #, python-brace-format 357 | msgid "not ejecting {0}: drive not ejectable" 358 | msgstr "未弹出 {0}:硬盘不可弹出" 359 | 360 | #: ../udiskie/mount.py:515 361 | #, python-brace-format 362 | msgid "ejecting {0}" 363 | msgstr "弹出 {0}" 364 | 365 | #: ../udiskie/mount.py:517 366 | #, python-brace-format 367 | msgid "ejected {0}" 368 | msgstr "已弹出 {0}" 369 | 370 | #: ../udiskie/mount.py:531 371 | #, python-brace-format 372 | msgid "not detaching {0}: unhandled device" 373 | msgstr "未取出 {0}:未处理设备" 374 | 375 | #: ../udiskie/mount.py:535 376 | #, python-brace-format 377 | msgid "not detaching {0}: drive not detachable" 378 | msgstr "未取出 {0}:硬盘不可取出" 379 | 380 | #: ../udiskie/mount.py:539 381 | #, python-brace-format 382 | msgid "detaching {0}" 383 | msgstr "取出 {0}" 384 | 385 | #: ../udiskie/mount.py:544 386 | #, python-brace-format 387 | msgid "detached {0}" 388 | msgstr "已取出 {0}" 389 | 390 | #: ../udiskie/mount.py:595 391 | #, python-brace-format 392 | msgid "not setting up {0}: already up" 393 | msgstr "未设置{0}:已设置" 394 | 395 | #: ../udiskie/mount.py:598 396 | #, python-brace-format 397 | msgid "not setting up {0}: not a file" 398 | msgstr "未设置 {0}:不是文件" 399 | 400 | #: ../udiskie/mount.py:600 401 | #, python-brace-format 402 | msgid "setting up loop device {0}" 403 | msgstr "设置环路设备 {0}" 404 | 405 | #: ../udiskie/mount.py:618 406 | #, python-brace-format 407 | msgid "" 408 | "Insufficient permission to open {0} in read-write mode. Retrying in read-" 409 | "only mode." 410 | msgstr "" 411 | "以读写模式打开 {0} 的权限不足。重试" 412 | "只读模式。" 413 | 414 | #: ../udiskie/mount.py:630 415 | #, python-brace-format 416 | msgid "set up {0} as {1}" 417 | msgstr "将 {0} 设为 {1}" 418 | 419 | #: ../udiskie/mount.py:645 420 | #, python-brace-format 421 | msgid "not deleting {0}: unhandled device" 422 | msgstr "未删除 {0}:未处理设备" 423 | 424 | #: ../udiskie/mount.py:649 425 | #, python-brace-format 426 | msgid "deleting {0}" 427 | msgstr "删除 {0}" 428 | 429 | #: ../udiskie/mount.py:651 430 | #, python-brace-format 431 | msgid "deleted {0}" 432 | msgstr "已删除 {0}" 433 | 434 | #: ../udiskie/mount.py:777 435 | #, python-brace-format 436 | msgid "Browse {0}" 437 | msgstr "浏览 {0}" 438 | 439 | #: ../udiskie/mount.py:778 440 | #, python-brace-format 441 | msgid "Hack on {0}" 442 | msgstr "破解 {0}" 443 | 444 | #: ../udiskie/mount.py:779 445 | #, python-brace-format 446 | msgid "Mount {0}" 447 | msgstr "挂载 {0}" 448 | 449 | #: ../udiskie/mount.py:780 450 | #, python-brace-format 451 | msgid "Unmount {0}" 452 | msgstr "卸载 {0}" 453 | 454 | #: ../udiskie/mount.py:781 455 | #, python-brace-format 456 | msgid "Unlock {0}" 457 | msgstr "解锁 {0}" 458 | 459 | #: ../udiskie/mount.py:782 460 | #, python-brace-format 461 | msgid "Lock {0}" 462 | msgstr "锁定 {0}" 463 | 464 | #: ../udiskie/mount.py:783 465 | #, python-brace-format 466 | msgid "Eject {1}" 467 | msgstr "弹出 {1}" 468 | 469 | #: ../udiskie/mount.py:784 470 | #, python-brace-format 471 | msgid "Unpower {1}" 472 | msgstr "断电 {1}" 473 | 474 | #: ../udiskie/mount.py:785 475 | #, python-brace-format 476 | msgid "Clear password for {0}" 477 | msgstr "清除 {0} 的密码" 478 | 479 | #: ../udiskie/mount.py:786 480 | #, python-brace-format 481 | msgid "Detach {0}" 482 | msgstr "取出 {0}" 483 | 484 | #: ../udiskie/notify.py:62 485 | msgid "Browse directory" 486 | msgstr "浏览目录" 487 | 488 | #: ../udiskie/notify.py:64 489 | msgid "Open terminal" 490 | msgstr "打开终端" 491 | 492 | #: ../udiskie/notify.py:68 493 | msgid "Device mounted" 494 | msgstr "设备已挂载" 495 | 496 | #: ../udiskie/notify.py:69 497 | #, python-brace-format 498 | msgid "{0.ui_label} mounted on {0.mount_paths[0]}" 499 | msgstr "{0.mount_paths[0]} 已挂载在 {0.ui_label} 上" 500 | 501 | #: ../udiskie/notify.py:80 502 | msgid "Device unmounted" 503 | msgstr "设备未挂载" 504 | 505 | #: ../udiskie/notify.py:81 506 | #, python-brace-format 507 | msgid "{0.ui_label} unmounted" 508 | msgstr "{0.ui_label} 未挂载" 509 | 510 | #: ../udiskie/notify.py:90 511 | msgid "Device locked" 512 | msgstr "设备已锁定" 513 | 514 | #: ../udiskie/notify.py:91 515 | #, python-brace-format 516 | msgid "{0.device_presentation} locked" 517 | msgstr "{0.device_presentation} 已锁定" 518 | 519 | #: ../udiskie/notify.py:100 520 | msgid "Device unlocked" 521 | msgstr "设备未锁定" 522 | 523 | #: ../udiskie/notify.py:101 524 | #, python-brace-format 525 | msgid "{0.device_presentation} unlocked" 526 | msgstr "{0.device_presentation} 未锁定" 527 | 528 | #: ../udiskie/notify.py:135 529 | msgid "Device added" 530 | msgstr "设备已添加" 531 | 532 | #: ../udiskie/notify.py:136 533 | #, python-brace-format 534 | msgid "device appeared on {0.device_presentation}" 535 | msgstr "设备已出现在 {0.device_presentation} 上" 536 | 537 | #: ../udiskie/notify.py:155 538 | msgid "Device removed" 539 | msgstr "设备已移除" 540 | 541 | #: ../udiskie/notify.py:156 542 | #, python-brace-format 543 | msgid "device disappeared on {0.device_presentation}" 544 | msgstr "设备在 {0.device_presentation} 上已消失" 545 | 546 | #: ../udiskie/notify.py:165 547 | #, python-brace-format 548 | msgid "" 549 | "failed to {0} {1}:\n" 550 | "{2}" 551 | msgstr "" 552 | "不能到 {0} {1}:\n" 553 | "{2}" 554 | 555 | #: ../udiskie/notify.py:167 556 | #, python-brace-format 557 | msgid "failed to {0} device {1}." 558 | msgstr "不能到 {0} device {1}." 559 | 560 | #: ../udiskie/notify.py:173 561 | msgid "Retry" 562 | msgstr "重试" 563 | 564 | #: ../udiskie/notify.py:176 565 | msgid "Job failed" 566 | msgstr "任务失败" 567 | 568 | #: ../udiskie/notify.py:207 569 | #, python-brace-format 570 | msgid "Failed to show notification: {0}" 571 | msgstr "无法显示通知: {0}" 572 | 573 | #: ../udiskie/prompt.py:96 574 | msgid "Show password" 575 | msgstr "显示密码" 576 | 577 | #: ../udiskie/prompt.py:101 578 | msgid "Open keyfile…" 579 | msgstr "打开密钥文件…" 580 | 581 | #: ../udiskie/prompt.py:108 582 | msgid "Cache password" 583 | msgstr "缓存密码" 584 | 585 | #: ../udiskie/prompt.py:123 586 | msgid "Open a keyfile to unlock the LUKS device" 587 | msgstr "打开密钥文件以解锁 LUKS 设备" 588 | 589 | #: ../udiskie/prompt.py:157 ../udiskie/prompt.py:167 590 | #, python-brace-format 591 | msgid "Enter password for {0.device_presentation}: " 592 | msgstr "输入 {0.device_presentation} 的密码: " 593 | 594 | #: ../udiskie/prompt.py:203 595 | msgid "Unknown device attribute {!r} in format string: {!r}" 596 | msgstr "未知设备属性 {!r} 格式字符串: {!r}" 597 | 598 | #: ../udiskie/prompt.py:255 599 | msgid "" 600 | "Can't find file browser: {0!r}. You may want to change the value for the '-" 601 | "f' option." 602 | msgstr "找不到文件浏览器: {0!r}. 您可能需要更改'-f'选项的值。" 603 | 604 | #: ../udiskie/tray.py:182 605 | msgid "Managed devices" 606 | msgstr "托管设备" 607 | 608 | #: ../udiskie/tray.py:198 609 | msgid "Mount disc image" 610 | msgstr "挂载光盘镜像" 611 | 612 | #: ../udiskie/tray.py:204 613 | msgid "Enable automounting" 614 | msgstr "启用自动挂载" 615 | 616 | #: ../udiskie/tray.py:210 617 | msgid "Enable notifications" 618 | msgstr "启用通知" 619 | 620 | #: ../udiskie/tray.py:219 621 | msgid "Quit" 622 | msgstr "退出" 623 | 624 | #: ../udiskie/tray.py:226 625 | msgid "Open disc image" 626 | msgstr "打开光盘镜像" 627 | 628 | #: ../udiskie/tray.py:228 629 | msgid "Open" 630 | msgstr "打开" 631 | 632 | #: ../udiskie/tray.py:229 633 | msgid "Cancel" 634 | msgstr "取消" 635 | 636 | #: ../udiskie/tray.py:269 637 | msgid "Invalid node!" 638 | msgstr "无效节点!" 639 | 640 | #: ../udiskie/tray.py:271 641 | msgid "No external devices" 642 | msgstr "无外置设备" 643 | 644 | #: ../udiskie/tray.py:387 645 | msgid "udiskie" 646 | msgstr "udiskie" 647 | 648 | #: ../udiskie/udisks2.py:661 649 | #, python-brace-format 650 | msgid "found device owning \"{0}\": \"{1}\"" 651 | msgstr "发现设备拥有 \"{0}\": \"{1}\"" 652 | 653 | #: ../udiskie/udisks2.py:664 654 | #, python-brace-format 655 | msgid "no device found owning \"{0}\"" 656 | msgstr "未找到设备拥有 \"{0}\"" 657 | 658 | #: ../udiskie/udisks2.py:683 659 | #, python-brace-format 660 | msgid "Daemon version: {0}" 661 | msgstr "守护程序版本: {0}" 662 | 663 | #: ../udiskie/udisks2.py:688 664 | #, python-brace-format 665 | msgid "Keyfile support: {0}" 666 | msgstr "支持密钥文件: {0}" 667 | 668 | #: ../udiskie/udisks2.py:767 669 | #, python-brace-format 670 | msgid "+++ {0}: {1}" 671 | msgstr "+++ {0}: {1}" 672 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coldfix/udiskie/b29aa02871e0894ce785412e7ffa5486444af3e1/screenshot.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = udiskie 3 | version = attr: udiskie.__version__ 4 | description = Removable disk automounter for udisks 5 | url = https://github.com/coldfix/udiskie 6 | long_description = file: README.rst, HACKING.rst, TRANSLATIONS.rst, CHANGES.rst 7 | author = Byron Clark 8 | author_email = byron@theclarkfamily.name 9 | maintainer = Thomas Gläßle 10 | maintainer_email = t_glaessle@gmx.de 11 | license = MIT 12 | license_file = COPYING 13 | project_urls = 14 | Bug Tracker = https://github.com/coldfix/udiskie/issues 15 | Source Code = https://github.com/coldfix/udiskie 16 | classifiers = 17 | Development Status :: 5 - Production/Stable 18 | Environment :: Console 19 | Environment :: X11 Applications :: GTK 20 | Intended Audience :: Developers 21 | Intended Audience :: End Users/Desktop 22 | Operating System :: POSIX :: Linux 23 | Programming Language :: Python :: 3.5 24 | Programming Language :: Python :: 3.6 25 | License :: OSI Approved :: MIT License 26 | Topic :: Desktop Environment 27 | Topic :: Software Development 28 | Topic :: System :: Filesystems 29 | Topic :: System :: Hardware 30 | Topic :: Utilities 31 | long_description_content_type = text/x-rst 32 | 33 | [options] 34 | packages = 35 | udiskie 36 | udiskie.icons 37 | zip_safe = true 38 | include_package_data = true 39 | python_requires = >=3.5 40 | install_requires = 41 | PyYAML 42 | docopt 43 | importlib_resources;python_version<'3.7' 44 | PyGObject 45 | 46 | [options.extras_require] 47 | password-cache = 48 | keyutils==0.3 49 | 50 | [options.entry_points] 51 | console_scripts = 52 | udiskie = udiskie.cli:Daemon.main 53 | udiskie-mount = udiskie.cli:Mount.main 54 | udiskie-umount = udiskie.cli:Umount.main 55 | udiskie-info = udiskie.cli:Info.main 56 | 57 | [flake8] 58 | # codes: https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes 59 | # default: ignore = E121,E123,E126,E133,E226,E241,E242,E704,W503,W504,W505 60 | ignore = E126,E221,E226,E241,E731,E741,W503,W504 61 | max-line-length = 84 62 | max-complexity = 14 63 | exclude = docs,.git,build,__pycache__,dist,hit_models 64 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, Command 2 | from distutils.command.build import build as orig_build 3 | 4 | from subprocess import call 5 | import logging 6 | from os import path 7 | from glob import glob 8 | 9 | 10 | completions_zsh = glob('completions/zsh/_*') 11 | completions_bash = glob('completions/bash/*') 12 | languages = [path.splitext(path.split(po_file)[1])[0] 13 | for po_file in glob('lang/*.po')] 14 | 15 | 16 | class build(orig_build): 17 | """Subclass build command to add a subcommand for building .mo files.""" 18 | sub_commands = orig_build.sub_commands + [('build_mo', None)] 19 | 20 | 21 | class build_mo(Command): 22 | 23 | """Create machine specific translation files (for i18n via gettext).""" 24 | 25 | description = 'Compile .po files into .mo files' 26 | 27 | def initialize_options(self): 28 | pass 29 | 30 | def finalize_options(self): 31 | pass 32 | 33 | def run(self): 34 | for lang in languages: 35 | po_file = 'lang/{}.po'.format(lang) 36 | mo_file = 'build/locale/{}/LC_MESSAGES/udiskie.mo'.format(lang) 37 | self.mkpath(path.dirname(mo_file)) 38 | self.make_file( 39 | po_file, mo_file, self.make_mo, 40 | [po_file, mo_file]) 41 | 42 | def make_mo(self, po_filename, mo_filename): 43 | """Create a machine object (.mo) from a portable object (.po) file.""" 44 | try: 45 | call(['msgfmt', po_filename, '-o', mo_filename]) 46 | except OSError as e: 47 | # ignore failures since i18n support is optional: 48 | logging.warning(e) 49 | 50 | 51 | setup( 52 | cmdclass={ 53 | 'build': build, 54 | 'build_mo': build_mo, 55 | }, 56 | data_files=[ 57 | ('share/bash-completion/completions', completions_bash), 58 | ('share/zsh/site-functions', completions_zsh), 59 | *[('share/locale/{}/LC_MESSAGES'.format(lang), 60 | ['build/locale/{}/LC_MESSAGES/udiskie.mo'.format(lang)]) 61 | for lang in languages], 62 | ], 63 | ) 64 | -------------------------------------------------------------------------------- /test/test_cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the udiskie.cache module. 3 | """ 4 | 5 | import unittest 6 | import time 7 | 8 | from udiskie.cache import PasswordCache 9 | 10 | 11 | class TestDev: 12 | 13 | def __init__(self, id_uuid): 14 | self.id_uuid = id_uuid 15 | 16 | 17 | class TestPasswordCache(unittest.TestCase): 18 | 19 | """ 20 | Tests for the udiskie.cache.PasswordCache class. 21 | """ 22 | 23 | # NOTE: The device names are different in each test so that they do not 24 | # interfere accidentally. 25 | 26 | def test_timeout(self): 27 | """The cached password expires after the specified timeout.""" 28 | device = TestDev('ALPHA') 29 | password = '{<}hëllo ωορλδ!{>}' 30 | cache = PasswordCache(1) 31 | cache[device] = password 32 | self.assertEqual(cache[device], password.encode('utf-8')) 33 | time.sleep(1.5) 34 | with self.assertRaises(KeyError): 35 | cache[device] 36 | 37 | def test_touch(self): 38 | """Key access refreshes the timeout.""" 39 | device = TestDev('BETA') 40 | password = '{<}hëllo ωορλδ!{>}' 41 | cache = PasswordCache(3) 42 | cache[device] = password 43 | time.sleep(2) 44 | self.assertEqual(cache[device], password.encode('utf-8')) 45 | time.sleep(2) 46 | self.assertEqual(cache[device], password.encode('utf-8')) 47 | time.sleep(4) 48 | with self.assertRaises(KeyError): 49 | cache[device] 50 | 51 | def test_revoke(self): 52 | """A key can be deleted manually.""" 53 | device = TestDev('GAMMA') 54 | password = '{<}hëllo ωορλδ!{>}' 55 | cache = PasswordCache(0) 56 | cache[device] = password 57 | self.assertEqual(cache[device], password.encode('utf-8')) 58 | del cache[device] 59 | with self.assertRaises(KeyError): 60 | cache[device] 61 | 62 | def test_update(self): 63 | device = TestDev('DELTA') 64 | password = '{<}hëllo ωορλδ!{>}' 65 | cache = PasswordCache(0) 66 | cache[device] = password 67 | self.assertEqual(cache[device], password.encode('utf-8')) 68 | cache[device] = password * 2 69 | self.assertEqual(cache[device], password.encode('utf-8')*2) 70 | del cache[device] 71 | with self.assertRaises(KeyError): 72 | cache[device] 73 | 74 | 75 | if __name__ == '__main__': 76 | unittest.main() 77 | -------------------------------------------------------------------------------- /test/test_match.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the udiskie.match module. 3 | 4 | These tests are intended to demonstrate and ensure the correct usage of the 5 | config file used by udiskie for custom device options. 6 | """ 7 | 8 | import unittest 9 | 10 | import tempfile 11 | import shutil 12 | import os.path 13 | import gc 14 | 15 | from udiskie.config import Config, match_config 16 | 17 | 18 | class TestDev: 19 | 20 | def __init__(self, object_path, id_type, id_uuid): 21 | self.device_file = object_path 22 | self.id_type = id_type 23 | self.id_uuid = id_uuid 24 | self.partition_slave = None 25 | self.luks_cleartext_slave = None 26 | 27 | 28 | class TestFilterMatcher(unittest.TestCase): 29 | 30 | """ 31 | Tests for the udiskie.match.FilterMatcher class. 32 | """ 33 | 34 | def setUp(self): 35 | """Create a temporary config file.""" 36 | self.base = tempfile.mkdtemp() 37 | self.config_file = os.path.join(self.base, 'filters.conf') 38 | 39 | with open(self.config_file, 'wt') as f: 40 | f.write(''' 41 | mount_options: 42 | - id_uuid: device-with-options 43 | options: noatime,nouser 44 | - id_type: vfat 45 | options: ro,nouser 46 | 47 | ignore_device: 48 | - id_uuid: ignored-DEVICE 49 | ''') 50 | 51 | self.filters = Config.from_file(self.config_file).device_config 52 | 53 | def mount_options(self, device): 54 | return match_config(self.filters, device, 'options', None) 55 | 56 | def ignore_device(self, device): 57 | return match_config(self.filters, device, 'ignore', False) 58 | 59 | def tearDown(self): 60 | """Remove the config file.""" 61 | gc.collect() 62 | shutil.rmtree(self.base) 63 | 64 | def test_ignored(self): 65 | """Test the FilterMatcher.is_ignored() method.""" 66 | self.assertTrue( 67 | self.ignore_device( 68 | TestDev('/ignore', 'vfat', 'IGNORED-device'))) 69 | self.assertFalse( 70 | self.ignore_device( 71 | TestDev('/options', 'vfat', 'device-with-options'))) 72 | self.assertFalse( 73 | self.ignore_device( 74 | TestDev('/nomatch', 'vfat', 'no-matching-id'))) 75 | 76 | def test_options(self): 77 | """Test the FilterMatcher.get_mount_options() method.""" 78 | self.assertEqual( 79 | ['noatime', 'nouser'], 80 | self.mount_options( 81 | TestDev('/options', 'vfat', 'device-with-options'))) 82 | self.assertEqual( 83 | ['noatime', 'nouser'], 84 | self.mount_options( 85 | TestDev('/optonly', 'ext', 'device-with-options'))) 86 | self.assertEqual( 87 | ['ro', 'nouser'], 88 | self.mount_options( 89 | TestDev('/fsonly', 'vfat', 'no-matching-id'))) 90 | self.assertEqual( 91 | None, 92 | self.mount_options( 93 | TestDev('/nomatch', 'ext', 'no-matching-id'))) 94 | 95 | 96 | if __name__ == '__main__': 97 | unittest.main() 98 | -------------------------------------------------------------------------------- /udiskie/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.5.7' 2 | -------------------------------------------------------------------------------- /udiskie/appindicator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Status icon using AppIndicator3. 3 | """ 4 | 5 | from gi.repository import Gtk 6 | 7 | from .async_ import Future 8 | from .depend import require_AppIndicator3 9 | 10 | 11 | AppIndicator3 = require_AppIndicator3() 12 | 13 | 14 | class AppIndicatorIcon: 15 | 16 | """ 17 | Show status icon using AppIndicator as backend. Replaces 18 | `udiskie.tray.StatusIcon` on ubuntu/unity. 19 | """ 20 | 21 | def __init__(self, menumaker, _icons): 22 | self._maker = menumaker 23 | self._menu = Gtk.Menu() 24 | self._indicator = AppIndicator3.Indicator.new( 25 | 'udiskie', 26 | _icons.get_icon_name('media'), 27 | AppIndicator3.IndicatorCategory.HARDWARE) 28 | self._indicator.set_status(AppIndicator3.IndicatorStatus.PASSIVE) 29 | self._indicator.set_menu(self._menu) 30 | # Get notified before menu is shown, see: 31 | # https://bugs.launchpad.net/screenlets/+bug/522152/comments/15 32 | dbusmenuserver = self._indicator.get_property('dbus-menu-server') 33 | self._dbusmenuitem = dbusmenuserver.get_property('root-node') 34 | self._conn = self._dbusmenuitem.connect('about-to-show', self._on_show) 35 | self.task = Future() 36 | menumaker._quit_action = self.destroy 37 | # Populate menu initially, so libdbusmenu does not ignore the 38 | # 'about-to-show': 39 | self._maker(self._menu) 40 | self._menu.show_all() 41 | 42 | def destroy(self): 43 | self.show(False) 44 | self._dbusmenuitem.disconnect(self._conn) 45 | self.task.set_result(True) 46 | 47 | @property 48 | def visible(self): 49 | status = self._indicator.get_status() 50 | return status == AppIndicator3.IndicatorStatus.ACTIVE 51 | 52 | def show(self, show=True): 53 | if show == self.visible: 54 | return 55 | status = (AppIndicator3.IndicatorStatus.ACTIVE if show else 56 | AppIndicator3.IndicatorStatus.PASSIVE) 57 | self._indicator.set_status(status) 58 | 59 | def _on_show(self, menu): 60 | # clear menu: 61 | for item in self._menu.get_children(): 62 | self._menu.remove(item) 63 | # repopulate: 64 | self._maker(self._menu) 65 | self._menu.show_all() 66 | -------------------------------------------------------------------------------- /udiskie/async_.py: -------------------------------------------------------------------------------- 1 | """ 2 | Lightweight asynchronous framework. 3 | 4 | This module defines the protocol used for asynchronous operations in udiskie. 5 | It is based on ideas from "Twisted" and the "yield from" expression in 6 | python3, but more lightweight (incomplete) and compatible with python2. 7 | """ 8 | 9 | import traceback 10 | from functools import partial 11 | from subprocess import CalledProcessError 12 | 13 | from gi.repository import GLib 14 | from gi.repository import Gio 15 | 16 | from .common import cachedproperty, wraps 17 | 18 | 19 | __all__ = [ 20 | 'pack', 21 | 'to_coro', 22 | 'run_bg', 23 | 'Future', 24 | 'gather', 25 | 'Task', 26 | ] 27 | 28 | 29 | ACTIVE_TASKS = set() 30 | 31 | 32 | def pack(*values): 33 | """Unpack a return tuple to a yield expression return value.""" 34 | # Schizophrenic returns from asyncs. Inspired by 35 | # gi.overrides.Gio.DBusProxy. 36 | if len(values) == 0: 37 | return None 38 | elif len(values) == 1: 39 | return values[0] 40 | else: 41 | return values 42 | 43 | 44 | class Future: 45 | 46 | """ 47 | Base class for asynchronous operations. 48 | 49 | One `Future' object represents an asynchronous operation. It allows for 50 | separate result and error handlers which can be set by appending to the 51 | `callbacks` and `errbacks` lists. 52 | 53 | Implementations must conform to the following very lightweight protocol: 54 | 55 | The task is started on initialization, but most not finish immediately. 56 | 57 | Success/error exit is signaled to the observer by calling exactly one of 58 | `self.set_result(value)` or `self.set_exception(exception)` when the 59 | operation finishes. 60 | 61 | For implementations, see :class:`Task` or :class:`Dialog`. 62 | """ 63 | 64 | @cachedproperty 65 | def callbacks(self): 66 | """Functions to be called on successful completion.""" 67 | return [] 68 | 69 | @cachedproperty 70 | def errbacks(self): 71 | """Functions to be called on error completion.""" 72 | return [] 73 | 74 | def _finish(self, callbacks, *args): 75 | """Set finished state and invoke specified callbacks [internal].""" 76 | return [fn(*args) for fn in callbacks] 77 | 78 | def set_result(self, value): 79 | """Signal successful completion.""" 80 | self._finish(self.callbacks, value) 81 | 82 | def set_exception(self, exception): 83 | """Signal unsuccessful completion.""" 84 | was_handled = self._finish(self.errbacks, exception) 85 | if not was_handled: 86 | traceback.print_exception( 87 | type(exception), exception, exception.__traceback__) 88 | 89 | def __await__(self): 90 | ACTIVE_TASKS.add(self) 91 | try: 92 | return (yield self) 93 | finally: 94 | ACTIVE_TASKS.remove(self) 95 | 96 | 97 | def to_coro(func): 98 | @wraps(func) 99 | async def coro(*args, **kwargs): 100 | return func(*args, **kwargs) 101 | return coro 102 | 103 | 104 | def run_bg(func): 105 | @wraps(func) 106 | def runner(*args, **kwargs): 107 | return ensure_future(func(*args, **kwargs)) 108 | return runner 109 | 110 | 111 | class gather(Future): 112 | 113 | """ 114 | Manages a collection of asynchronous tasks. 115 | 116 | The callbacks are executed when all of the subtasks have completed. 117 | """ 118 | 119 | def __init__(self, *tasks): 120 | """Create from a list of `Future`-s.""" 121 | tasks = list(tasks) 122 | self._done = False 123 | self._results = {} 124 | self._num_tasks = len(tasks) 125 | if not tasks: 126 | run_soon(self.set_result, []) 127 | for idx, task in enumerate(tasks): 128 | task = ensure_future(task) 129 | task.callbacks.append(partial(self._subtask_result, idx)) 130 | task.errbacks.append(partial(self._subtask_error, idx)) 131 | 132 | def _subtask_result(self, idx, value): 133 | """Receive a result from a single subtask.""" 134 | self._results[idx] = value 135 | if len(self._results) == self._num_tasks: 136 | self.set_result([ 137 | self._results[i] 138 | for i in range(self._num_tasks) 139 | ]) 140 | 141 | def _subtask_error(self, idx, error): 142 | """Receive an error from a single subtask.""" 143 | self.set_exception(error) 144 | self.errbacks.clear() 145 | 146 | 147 | def call_func(fn, *args): 148 | """ 149 | Call the function with the specified arguments but return None. 150 | 151 | This rather boring helper function is used by run_soon to make sure the 152 | function is executed only once. 153 | """ 154 | # NOTE: Apparently, idle_add does not re-execute its argument if an 155 | # exception is raised. So it's okay to let exceptions propagate. 156 | fn(*args) 157 | 158 | 159 | def run_soon(fn, *args): 160 | """Run the function once.""" 161 | GLib.idle_add(call_func, fn, *args) 162 | 163 | 164 | def sleep(seconds): 165 | future = Future() 166 | GLib.timeout_add(int(seconds*1000), future.set_result, True) 167 | return future 168 | 169 | 170 | def ensure_future(awaitable): 171 | if isinstance(awaitable, Future): 172 | return awaitable 173 | return Task(iter(awaitable.__await__())) 174 | 175 | 176 | class Task(Future): 177 | 178 | """Turns a generator into a Future.""" 179 | 180 | def __init__(self, generator): 181 | """Create and start a ``Task`` from the specified generator.""" 182 | self._generator = generator 183 | run_soon(self._resume, next, self._generator) 184 | 185 | def _resume(self, func, *args): 186 | """Resume the coroutine by throwing a value or returning a value from 187 | the ``await`` and handle further awaits.""" 188 | try: 189 | value = func(*args) 190 | except StopIteration as e: 191 | self._generator.close() 192 | self.set_result(e.value) 193 | except Exception as e: 194 | self._generator.close() 195 | self.set_exception(e) 196 | else: 197 | assert isinstance(value, Future) 198 | value.callbacks.append(partial(self._resume, self._generator.send)) 199 | value.errbacks.append(partial(self._resume, self._generator.throw)) 200 | 201 | 202 | def gio_callback(proxy, result, future): 203 | future.set_result(result) 204 | 205 | 206 | async def exec_subprocess(argv, capture=True): 207 | """ 208 | An Future task that represents a subprocess. If successful, the task's 209 | result is set to the collected STDOUT of the subprocess. 210 | 211 | :raises subprocess.CalledProcessError: if the subprocess returns a non-zero 212 | exit code 213 | """ 214 | future = Future() 215 | flags = ((Gio.SubprocessFlags.STDOUT_PIPE if capture else 216 | Gio.SubprocessFlags.NONE) | 217 | Gio.SubprocessFlags.STDIN_INHERIT) 218 | process = Gio.Subprocess.new(argv, flags) 219 | stdin_buf = None 220 | cancellable = None 221 | process.communicate_async( 222 | stdin_buf, cancellable, gio_callback, future) 223 | result = await future 224 | success, stdout, stderr = process.communicate_finish(result) 225 | stdout = stdout.get_data() if capture else None # GLib.Bytes -> bytes 226 | if not success: 227 | raise RuntimeError("Subprocess did not exit normally!") 228 | exit_code = process.get_exit_status() 229 | if exit_code != 0: 230 | raise CalledProcessError( 231 | "Subprocess returned a non-zero exit-status!", 232 | exit_code, 233 | stdout) 234 | return stdout 235 | -------------------------------------------------------------------------------- /udiskie/automount.py: -------------------------------------------------------------------------------- 1 | """ 2 | Automount utility. 3 | """ 4 | 5 | from .common import DaemonBase 6 | from .async_ import run_bg 7 | 8 | 9 | __all__ = ['AutoMounter'] 10 | 11 | 12 | class AutoMounter(DaemonBase): 13 | 14 | """ 15 | Automount utility. 16 | 17 | Being connected to the udiskie daemon, this component automatically 18 | mounts newly discovered external devices. Instances are constructed with 19 | a Mounter object, like so: 20 | 21 | >>> automounter = AutoMounter(Mounter(udisks=Daemon())) 22 | >>> automounter.activate() 23 | """ 24 | 25 | def __init__(self, mounter, automount=True): 26 | """Store mounter as member variable.""" 27 | self._mounter = mounter 28 | self._automount = automount 29 | self.events = { 30 | 'device_changed': self.device_changed, 31 | 'device_added': self.auto_add, 32 | 'media_added': self.auto_add, 33 | } 34 | 35 | def is_on(self): 36 | return self._automount 37 | 38 | def toggle_on(self): 39 | self._automount = not self._automount 40 | 41 | def device_changed(self, old_state, new_state): 42 | """Mount newly mountable devices.""" 43 | # udisks2 sometimes adds empty devices and later updates them - which 44 | # makes is_external become true at a time later than device_added: 45 | if (self._mounter.is_addable(new_state) 46 | and not self._mounter.is_addable(old_state) 47 | and not self._mounter.is_removable(old_state)): 48 | self.auto_add(new_state) 49 | 50 | @run_bg 51 | def auto_add(self, device): 52 | return self._mounter.auto_add(device, automount=self._automount) 53 | -------------------------------------------------------------------------------- /udiskie/cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility for temporarily caching passwords. 3 | """ 4 | 5 | import keyutils 6 | 7 | # This import fails in python-keyring-keyutils, which is a not (yet) supported 8 | # alternative for the python-keyring package. This lets us distinguish between 9 | # the two identically named python packages (=keyring). 10 | from keyutils import KEY_SPEC_PROCESS_KEYRING 11 | 12 | 13 | class PasswordCache: 14 | 15 | def __init__(self, timeout): 16 | self.timeout = timeout 17 | self.keyring = KEY_SPEC_PROCESS_KEYRING 18 | 19 | def _key(self, device): 20 | return device.id_uuid.encode('utf-8') 21 | 22 | def _key_id(self, device): 23 | key = self._key(device) 24 | try: 25 | key_id = keyutils.request_key(key, self.keyring) 26 | except keyutils.Error: 27 | raise KeyError("Key has been revoked!") from None 28 | if key_id is None: 29 | raise KeyError("Key not cached!") 30 | return key_id 31 | 32 | def __contains__(self, device): 33 | try: 34 | self._key_id(device) 35 | return True 36 | except KeyError: 37 | return False 38 | 39 | def __getitem__(self, device): 40 | key_id = self._key_id(device) 41 | self._touch(key_id) 42 | try: 43 | return keyutils.read_key(key_id) 44 | except keyutils.Error: 45 | raise KeyError("Key not cached!") from None 46 | 47 | def __setitem__(self, device, value): 48 | key = self._key(device) 49 | if isinstance(value, str): 50 | value = value.encode('utf-8') 51 | key_id = keyutils.add_key(key, value, self.keyring) 52 | self._touch(key_id) 53 | 54 | def __delitem__(self, device): 55 | key_id = self._key_id(device) 56 | keyutils.revoke(key_id) 57 | 58 | def _touch(self, key_id): 59 | if self.timeout > 0: 60 | keyutils.set_timeout(key_id, self.timeout) 61 | -------------------------------------------------------------------------------- /udiskie/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common utilities. 3 | """ 4 | 5 | import os.path 6 | import sys 7 | import traceback 8 | 9 | 10 | __all__ = [ 11 | 'wraps', 12 | 'Emitter', 13 | 'samefile', 14 | 'sameuuid', 15 | 'setdefault', 16 | 'extend', 17 | 'cachedproperty', 18 | 'decode_ay', 19 | 'exc_message', 20 | 'format_exc', 21 | ] 22 | 23 | 24 | try: 25 | from black_magic.decorator import wraps 26 | except ImportError: 27 | from functools import wraps 28 | 29 | 30 | class Emitter: 31 | 32 | """Simple event emitter for a known finite set of events.""" 33 | 34 | def __init__(self, event_names=(), *args, **kwargs): 35 | """Initialize with empty lists of event handlers.""" 36 | super().__init__(*args, **kwargs) 37 | self._event_handlers = {} 38 | for evt in event_names: 39 | self._event_handlers[evt] = [] 40 | 41 | def trigger(self, event, *args): 42 | """Trigger event by name.""" 43 | for handler in self._event_handlers[event]: 44 | handler(*args) 45 | 46 | def connect(self, event, handler): 47 | """Connect an event handler.""" 48 | self._event_handlers[event].append(handler) 49 | 50 | def disconnect(self, event, handler): 51 | """Disconnect an event handler.""" 52 | self._event_handlers[event].remove(handler) 53 | 54 | 55 | def samefile(a: str, b: str) -> bool: 56 | """Check if two paths represent the same file.""" 57 | try: 58 | return os.path.samefile(a, b) 59 | except OSError: 60 | return os.path.normpath(a) == os.path.normpath(b) 61 | 62 | 63 | def sameuuid(a: str, b: str) -> bool: 64 | """Compare two UUIDs.""" 65 | return a and b and a.lower() == b.lower() 66 | 67 | 68 | def setdefault(self: dict, other: dict): 69 | """Like .update() but values in self take priority.""" 70 | for k, v in other.items(): 71 | self.setdefault(k, v) 72 | 73 | 74 | def extend(a: dict, b: dict) -> dict: 75 | """Merge two dicts and return a new dict. Much like subclassing works.""" 76 | res = a.copy() 77 | res.update(b) 78 | return res 79 | 80 | 81 | def cachedmethod(func): 82 | """A memoize decorator for object methods.""" 83 | key = '_' + func.__name__ 84 | 85 | @wraps(func) 86 | def get(self, *args): 87 | try: 88 | cache = getattr(self, key) 89 | except AttributeError: 90 | cache = {} 91 | setattr(self, key, cache) 92 | try: 93 | val = cache[args] 94 | except KeyError: 95 | val = cache[args] = func(self, *args) 96 | return val 97 | return get 98 | 99 | 100 | def cachedproperty(func): 101 | """A memoize decorator for class properties.""" 102 | key = '_' + func.__name__ 103 | 104 | @wraps(func) 105 | def get(self): 106 | try: 107 | return getattr(self, key) 108 | except AttributeError: 109 | val = func(self) 110 | setattr(self, key, val) 111 | return val 112 | return property(get) 113 | 114 | 115 | # ---------------------------------------- 116 | # udisks.Device helper classes 117 | # ---------------------------------------- 118 | 119 | class AttrDictView: 120 | 121 | """Provide attribute access view to a dictionary.""" 122 | 123 | def __init__(self, data): 124 | self.__data = data 125 | 126 | def __getattr__(self, key): 127 | try: 128 | return self.__data[key] 129 | except KeyError: 130 | raise AttributeError(key) from None 131 | 132 | 133 | class ObjDictView: 134 | 135 | """Provide dict-like access view to the attributes of an object.""" 136 | 137 | def __init__(self, object, valid=None): 138 | self._object = object 139 | self._valid = valid 140 | 141 | def __getitem__(self, key): 142 | if self._valid is None or key in self._valid: 143 | try: 144 | return getattr(self._object, key) 145 | except AttributeError: 146 | raise KeyError(key) from None 147 | raise KeyError("Unknown key: {}".format(key)) 148 | 149 | 150 | class DaemonBase: 151 | 152 | active = False 153 | 154 | def activate(self): 155 | udisks = self._mounter.udisks 156 | for event, handler in self.events.items(): 157 | udisks.connect(event, handler) 158 | self.active = True 159 | 160 | def deactivate(self): 161 | udisks = self._mounter.udisks 162 | for event, handler in self.events.items(): 163 | udisks.disconnect(event, handler) 164 | self.active = False 165 | 166 | 167 | # ---------------------------------------- 168 | # byte array to string conversion 169 | # ---------------------------------------- 170 | 171 | def decode_ay(ay): 172 | """Convert binary blob from DBus queries to strings.""" 173 | if ay is None: 174 | return '' 175 | elif isinstance(ay, str): 176 | return ay 177 | elif isinstance(ay, bytes): 178 | return ay.decode('utf-8') 179 | else: 180 | # dbus.Array([dbus.Byte]) or any similar sequence type: 181 | return bytearray(ay).rstrip(bytearray((0,))).decode('utf-8') 182 | 183 | 184 | def is_utf8(bs): 185 | """Check if the given bytes string is utf-8 decodable.""" 186 | try: 187 | bs.decode('utf-8') 188 | return True 189 | except UnicodeDecodeError: 190 | return False 191 | 192 | 193 | def exc_message(exc): 194 | """Get an exception message.""" 195 | message = getattr(exc, 'message', None) 196 | return message or str(exc) 197 | 198 | 199 | def format_exc(*exc_info): 200 | """Show exception with traceback.""" 201 | typ, exc, tb = exc_info or sys.exc_info() 202 | error = traceback.format_exception(typ, exc, tb) 203 | return "".join(error) 204 | -------------------------------------------------------------------------------- /udiskie/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Config utilities. 3 | 4 | For an example config file, see the manual. If you don't have the man page 5 | installed, a raw version is available in doc/udiskie.8.txt. 6 | """ 7 | 8 | import logging 9 | import os 10 | import fnmatch 11 | 12 | from .common import exc_message 13 | from .locale import _ 14 | 15 | 16 | __all__ = ['DeviceFilter', 17 | 'match_config', 18 | 'Config'] 19 | 20 | 21 | def lower(s): 22 | try: 23 | return s.lower() 24 | except AttributeError: 25 | return s 26 | 27 | 28 | def format_dict(d): 29 | return '{' + ', '.join([ 30 | _format_item(k, v) 31 | for k, v in d.items() 32 | ]) + '}' 33 | 34 | 35 | def _format_item(k, v): 36 | if isinstance(v, bool): 37 | return k if v else '!' + k 38 | else: 39 | return '{}={}'.format(k, v) 40 | 41 | 42 | def match_value(value, pattern): 43 | if isinstance(value, (list, tuple)): 44 | return any(match_value(v, pattern) for v in value) 45 | if isinstance(pattern, (list, tuple)): 46 | return any(match_value(value, p) for p in pattern) 47 | if isinstance(value, str) and isinstance(pattern, str): 48 | return fnmatch.fnmatch(value.lower(), pattern.lower()) 49 | return lower(value) == lower(pattern) 50 | 51 | 52 | class DeviceFilter: 53 | 54 | """Associate a certain value to matching devices.""" 55 | 56 | VALID_PARAMETERS = [ 57 | 'is_drive', 58 | 'is_block', 59 | 'is_partition_table', 60 | 'is_partition', 61 | 'is_filesystem', 62 | 'is_luks', 63 | 'is_loop', 64 | # FIXME: experimental/undocumented 65 | 'is_loopfile', 66 | 'is_toplevel', 67 | 'is_detachable', 68 | 'is_ejectable', 69 | 'has_media', 70 | 'device_file', 71 | 'device_presentation', 72 | 'device_size', 73 | 'device_id', 74 | 'id_usage', 75 | 'is_crypto', 76 | 'is_ignored', 77 | 'id_type', 78 | 'id_label', 79 | 'id_uuid', 80 | 'is_luks_cleartext', 81 | 'is_external', 82 | 'is_systeminternal', 83 | 'is_mounted', 84 | 'mount_paths', 85 | 'mount_path', 86 | 'is_unlocked', 87 | 'in_use', 88 | 'should_automount', 89 | 'ui_label', 90 | 'loop_file', 91 | 'setup_by_uid', 92 | 'autoclear', 93 | 'symlinks', 94 | 'drive_model', 95 | 'drive_vendor', 96 | 'drive_label', 97 | 'ui_device_label', 98 | 'ui_device_presentation', 99 | 'ui_id_label', 100 | 'ui_id_uuid', 101 | ] 102 | 103 | def __init__(self, match): 104 | """Construct from dict of device matching attributes.""" 105 | self._log = logging.getLogger(__name__) 106 | self._match = match = match.copy() 107 | self._values = {} 108 | # mount options: 109 | if 'options' in match: 110 | options = match.pop('options') 111 | if isinstance(options, str): 112 | options = [o.strip() for o in options.split(',')] 113 | self._values['options'] = options 114 | # ignore device: 115 | if 'ignore' in match: 116 | self._values['ignore'] = match.pop('ignore') 117 | # automount: 118 | if 'automount' in match: 119 | self._values['automount'] = match.pop('automount') 120 | # keyfile: 121 | if 'keyfile' in match: 122 | keyfile = match.pop('keyfile') 123 | keyfile = os.path.expandvars(keyfile) 124 | keyfile = os.path.expanduser(keyfile) 125 | self._values['keyfile'] = keyfile 126 | if 'skip' in match: 127 | self._values['skip'] = match.pop('skip') 128 | # the use of list() makes deletion inside the loop safe: 129 | for k in list(self._match): 130 | if k not in self.VALID_PARAMETERS: 131 | self._log.error(_('Unknown matching attribute: {!r}', k)) 132 | del self._match[k] 133 | self._log.debug(_('new rule: {0}', self)) 134 | 135 | def __str__(self): 136 | return _('{0} -> {1}', 137 | format_dict(self._match), 138 | format_dict(self._values)) 139 | 140 | def match(self, device): 141 | """Check if the device object matches this filter.""" 142 | return all(match_value(getattr(device, k), v) 143 | for k, v in self._match.items()) 144 | 145 | def has_value(self, kind): 146 | return kind in self._values 147 | 148 | def value(self, kind, device): 149 | """ 150 | Get the value for the device object associated with this filter. 151 | 152 | If :meth:`match` is False for the device, the return value of this 153 | method is undefined. 154 | """ 155 | self._log.debug(_('{0} matched {1}', 156 | device.device_file or device.object_path, self)) 157 | return self._values[kind] 158 | 159 | 160 | class MountOptions(DeviceFilter): 161 | 162 | """Associate a list of mount options to matched devices.""" 163 | 164 | def __init__(self, config_item): 165 | config_item.setdefault('options', None) 166 | super().__init__(config_item) 167 | 168 | 169 | class IgnoreDevice(DeviceFilter): 170 | 171 | """Associate a boolean ignore flag to matched devices.""" 172 | 173 | def __init__(self, config_item): 174 | config_item.setdefault('ignore', True) 175 | super().__init__(config_item) 176 | 177 | 178 | def match_config(filters, device, kind, default): 179 | """ 180 | Matches devices against multiple :class:`DeviceFilter`s. 181 | 182 | :param list filters: device filters 183 | :param Device device: device to be mounted 184 | :param str kind: value kind 185 | :param default: default value 186 | :returns: value of the first matching filter 187 | """ 188 | while device is not None: 189 | for f in filters: 190 | if f.has_value(kind) and f.match(device): 191 | return f.value(kind, device) 192 | # 'skip' allows skipping further rules and directly moving on 193 | # lookup on the parent device: 194 | if f.has_value('skip') and f.match(device) and ( 195 | f.value('skip', device) in (True, 'all', kind)): 196 | break 197 | device = device.partition_slave or device.luks_cleartext_slave 198 | return default 199 | 200 | 201 | class Config: 202 | 203 | """Udiskie config in memory representation.""" 204 | 205 | def __init__(self, data): 206 | """Initialize with preparsed data dict.""" 207 | self._data = data or {} 208 | 209 | @classmethod 210 | def default_pathes(cls): 211 | """Return the default config file paths as a list.""" 212 | config_home = ( 213 | os.environ.get('XDG_CONFIG_HOME') or 214 | os.path.expanduser('~/.config')) 215 | return [os.path.join(config_home, 'udiskie', 'config.yml'), 216 | os.path.join(config_home, 'udiskie', 'config.json')] 217 | 218 | @classmethod 219 | def from_file(cls, path=None): 220 | """ 221 | Read YAML config file. Returns Config object. 222 | 223 | :raises IOError: if the path does not exist 224 | """ 225 | # None => use default 226 | if path is None: 227 | for path in cls.default_pathes(): 228 | try: 229 | return cls.from_file(path) 230 | except IOError as e: 231 | logging.getLogger(__name__).debug( 232 | _("Failed to read config file: {0}", exc_message(e))) 233 | except ImportError as e: 234 | logging.getLogger(__name__).warn( 235 | _("Failed to read {0!r}: {1}", path, exc_message(e))) 236 | return cls({}) 237 | # False/'' => no config 238 | if not path: 239 | return cls({}) 240 | if os.path.splitext(path)[1].lower() == '.json': 241 | from json import load 242 | else: 243 | from yaml import safe_load as load 244 | with open(path) as f: 245 | return cls(load(f)) 246 | 247 | @property 248 | def device_config(self): 249 | device_config = map(DeviceFilter, self._data.get('device_config', [])) 250 | mount_options = map(MountOptions, self._data.get('mount_options', [])) 251 | ignore_device = map(IgnoreDevice, self._data.get('ignore_device', [])) 252 | return list(device_config) + list(mount_options) + list(ignore_device) 253 | 254 | @property 255 | def program_options(self): 256 | """Get the program options dictionary from the config file.""" 257 | return self._data.get('program_options', {}).copy() 258 | 259 | @property 260 | def notifications(self): 261 | """Get the notification timeouts dictionary from the config file.""" 262 | return self._data.get('notifications', {}).copy() 263 | 264 | @property 265 | def icon_names(self): 266 | """Get the icon names dictionary from the config file.""" 267 | return self._data.get('icon_names', {}).copy() 268 | 269 | @property 270 | def notification_actions(self): 271 | """Get the notification actions dictionary from the config file.""" 272 | return self._data.get('notification_actions', {}).copy() 273 | 274 | @property 275 | def quickmenu_actions(self): 276 | """Get the set of actions to be shown in the quickmenu (left-click).""" 277 | return self._data.get('quickmenu_actions', None) 278 | -------------------------------------------------------------------------------- /udiskie/dbus.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common DBus utilities. 3 | """ 4 | 5 | from functools import partial 6 | 7 | from gi.repository import Gio 8 | from gi.repository import GLib 9 | 10 | from .async_ import gio_callback, pack, Future 11 | 12 | 13 | __all__ = [ 14 | 'InterfaceProxy', 15 | 'PropertiesProxy', 16 | 'ObjectProxy', 17 | 'BusProxy', 18 | 'connect_service', 19 | 'MethodsProxy', 20 | ] 21 | 22 | 23 | unpack_variant = GLib.Variant.unpack 24 | 25 | 26 | async def call(proxy, method_name, signature, args, flags=0, timeout_msec=-1): 27 | """ 28 | Asynchronously call the specified method on a DBus proxy object. 29 | 30 | :param Gio.DBusProxy proxy: 31 | :param str method_name: 32 | :param str signature: 33 | :param tuple args: 34 | :param int flags: 35 | :param int timeout_msec: 36 | """ 37 | future = Future() 38 | cancellable = None 39 | proxy.call( 40 | method_name, 41 | GLib.Variant(signature, tuple(args)), 42 | flags, 43 | timeout_msec, 44 | cancellable, 45 | gio_callback, 46 | future, 47 | ) 48 | result = await future 49 | value = proxy.call_finish(result) 50 | return pack(*unpack_variant(value)) 51 | 52 | 53 | async def call_with_fd_list(proxy, method_name, signature, args, fds, 54 | flags=0, timeout_msec=-1): 55 | """ 56 | Asynchronously call the specified method on a DBus proxy object. 57 | 58 | :param Gio.DBusProxy proxy: 59 | :param str method_name: 60 | :param str signature: 61 | :param tuple args: 62 | :param list fds: 63 | :param int flags: 64 | :param int timeout_msec: 65 | """ 66 | future = Future() 67 | cancellable = None 68 | fd_list = Gio.UnixFDList.new_from_array(fds) 69 | proxy.call_with_unix_fd_list( 70 | method_name, 71 | GLib.Variant(signature, tuple(args)), 72 | flags, 73 | timeout_msec, 74 | fd_list, 75 | cancellable, 76 | gio_callback, 77 | future, 78 | ) 79 | result = await future 80 | value, fds = proxy.call_with_unix_fd_list_finish(result) 81 | return pack(*unpack_variant(value)) 82 | 83 | 84 | class InterfaceProxy: 85 | 86 | """ 87 | DBus proxy object for a specific interface. 88 | 89 | :ivar str object_path: object path of the DBus object 90 | :ivar Gio.DBusProxy _proxy: underlying proxy object 91 | """ 92 | 93 | def __init__(self, proxy): 94 | """ 95 | Initialize property and method attribute accessors for the interface. 96 | 97 | :param Gio.DBusProxy proxy: accessed object 98 | """ 99 | self._proxy = proxy 100 | self.object_path = proxy.get_object_path() 101 | 102 | @property 103 | def object(self): 104 | """Get an ObjectProxy instanec for the underlying object.""" 105 | proxy = self._proxy 106 | return ObjectProxy(proxy.get_connection(), 107 | proxy.get_name(), 108 | proxy.get_object_path()) 109 | 110 | def connect(self, event, handler): 111 | """Connect to a DBus signal, returns subscription id (int).""" 112 | interface = self._proxy.get_interface_name() 113 | return self.object.connect(interface, event, handler) 114 | 115 | def call(self, method_name, signature='()', *args): 116 | return call(self._proxy, method_name, signature, args) 117 | 118 | 119 | class PropertiesProxy(InterfaceProxy): 120 | 121 | Interface = 'org.freedesktop.DBus.Properties' 122 | 123 | def __init__(self, proxy, interface_name=None): 124 | super().__init__(proxy) 125 | self.interface_name = interface_name 126 | 127 | def GetAll(self, interface_name=None): 128 | return self.call('GetAll', '(s)', 129 | interface_name or self.interface_name) 130 | 131 | 132 | class ObjectProxy: 133 | 134 | """Simple proxy class for a DBus object.""" 135 | 136 | def __init__(self, connection, bus_name, object_path): 137 | """ 138 | Initialize member variables. 139 | 140 | :ivar Gio.DBusConnection connection: 141 | :ivar str bus_name: 142 | :ivar str object_path: 143 | 144 | This performs no IO at all. 145 | """ 146 | self.connection = connection 147 | self.bus_name = bus_name 148 | self.object_path = object_path 149 | 150 | def _get_interface(self, name): 151 | """Get a Future(Gio.DBusProxy) for the specified interface.""" 152 | return proxy_new( 153 | self.connection, 154 | Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES | 155 | Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS, 156 | info=None, 157 | name=self.bus_name, 158 | object_path=self.object_path, 159 | interface_name=name, 160 | ) 161 | 162 | async def get_interface(self, name): 163 | """Get an InterfaceProxy for the specified interface.""" 164 | proxy = await self._get_interface(name) 165 | return InterfaceProxy(proxy) 166 | 167 | async def get_property_interface(self, interface_name=None): 168 | proxy = await self._get_interface(PropertiesProxy.Interface) 169 | return PropertiesProxy(proxy, interface_name) 170 | 171 | @property 172 | def bus(self): 173 | """Get a BusProxy for the underlying bus.""" 174 | return BusProxy(self.connection, self.bus_name) 175 | 176 | def connect(self, interface, event, handler): 177 | """Connect to a DBus signal. Returns subscription id (int).""" 178 | object_path = self.object_path 179 | return self.bus.connect(interface, event, object_path, handler) 180 | 181 | async def call(self, interface_name, method_name, signature='()', *args): 182 | proxy = await self.get_interface(interface_name) 183 | result = await proxy.call(method_name, signature, *args) 184 | return result 185 | 186 | 187 | class BusProxy: 188 | 189 | """ 190 | Simple proxy class for a connected bus. 191 | 192 | :ivar Gio.DBusConnection connection: 193 | :ivar str bus_name: 194 | """ 195 | 196 | def __init__(self, connection, bus_name): 197 | """ 198 | Initialize member variables. 199 | 200 | :param Gio.DBusConnection connection: 201 | :param str bus_name: 202 | 203 | This performs IO at all. 204 | """ 205 | self.connection = connection 206 | self.bus_name = bus_name 207 | 208 | def get_object(self, object_path): 209 | """Get an ObjectProxy representing the specified object.""" 210 | return ObjectProxy(self.connection, self.bus_name, object_path) 211 | 212 | def connect(self, interface, event, object_path, handler): 213 | """ 214 | Connect to a DBus signal. If ``object_path`` is None, subscribe for 215 | all objects and invoke the callback with the object_path as its first 216 | argument. 217 | """ 218 | if object_path: 219 | def callback(connection, sender_name, object_path, 220 | interface_name, signal_name, parameters): 221 | return handler(*unpack_variant(parameters)) 222 | else: 223 | def callback(connection, sender_name, object_path, 224 | interface_name, signal_name, parameters): 225 | return handler(object_path, *unpack_variant(parameters)) 226 | return self.connection.signal_subscribe( 227 | self.bus_name, 228 | interface, 229 | event, 230 | object_path, 231 | None, 232 | Gio.DBusSignalFlags.NONE, 233 | callback, 234 | ) 235 | 236 | def disconnect(self, subscription_id): 237 | """Disconnect a DBus signal subscription.""" 238 | self.connection.signal_unsubscribe(subscription_id) 239 | 240 | 241 | async def proxy_new(connection, flags, info, name, object_path, interface_name): 242 | """Asynchronously call the specified method on a DBus proxy object.""" 243 | future = Future() 244 | cancellable = None 245 | Gio.DBusProxy.new( 246 | connection, 247 | flags, 248 | info, 249 | name, 250 | object_path, 251 | interface_name, 252 | cancellable, 253 | gio_callback, 254 | future, 255 | ) 256 | result = await future 257 | value = Gio.DBusProxy.new_finish(result) 258 | if value is None: 259 | raise RuntimeError("Failed to connect DBus object!") 260 | return value 261 | 262 | 263 | async def proxy_new_for_bus(bus_type, flags, info, name, object_path, 264 | interface_name): 265 | """Asynchronously call the specified method on a DBus proxy object.""" 266 | future = Future() 267 | cancellable = None 268 | Gio.DBusProxy.new_for_bus( 269 | bus_type, 270 | flags, 271 | info, 272 | name, 273 | object_path, 274 | interface_name, 275 | cancellable, 276 | gio_callback, 277 | future, 278 | ) 279 | result = await future 280 | value = Gio.DBusProxy.new_for_bus_finish(result) 281 | if value is None: 282 | raise RuntimeError("Failed to connect DBus object!") 283 | return value 284 | 285 | 286 | async def connect_service(bus_name, object_path, interface): 287 | """Connect to the service object on DBus, return InterfaceProxy.""" 288 | proxy = await proxy_new_for_bus( 289 | Gio.BusType.SYSTEM, 290 | Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES | 291 | Gio.DBusProxyFlags.DO_NOT_CONNECT_SIGNALS, 292 | info=None, 293 | name=bus_name, 294 | object_path=object_path, 295 | interface_name=interface, 296 | ) 297 | return InterfaceProxy(proxy) 298 | 299 | 300 | class MethodsProxy: 301 | 302 | """Provide methods as attributes for one interface of a DBus object.""" 303 | 304 | def __init__(self, object_proxy, interface_name): 305 | """Initialize from (ObjectProxy, str).""" 306 | self._object_proxy = object_proxy 307 | self._interface_name = interface_name 308 | 309 | def __getattr__(self, name): 310 | """Get a proxy for the specified method on this interface.""" 311 | return partial(self._object_proxy.call, self._interface_name, name) 312 | -------------------------------------------------------------------------------- /udiskie/depend.py: -------------------------------------------------------------------------------- 1 | """ 2 | Make sure that the correct versions of gobject introspection dependencies 3 | are installed. 4 | """ 5 | 6 | import os 7 | import logging 8 | 9 | from gi import require_version 10 | 11 | from .locale import _ 12 | 13 | require_version('Gio', '2.0') 14 | require_version('GLib', '2.0') 15 | 16 | 17 | def check_call(exc_type, func, *args): 18 | try: 19 | func(*args) 20 | return True 21 | except exc_type: 22 | return False 23 | 24 | 25 | def check_version(package, version): 26 | if check_call(ValueError, require_version, package, version): 27 | return (package, version) 28 | 29 | 30 | _in_X = bool(os.environ.get('DISPLAY')) 31 | _in_Wayland = bool(os.environ.get('WAYLAND_DISPLAY')) 32 | if not _in_X and not _in_Wayland and os.environ.get('XDG_RUNTIME_DIR'): 33 | _in_Wayland = os.path.exists(os.path.join( 34 | os.environ.get('XDG_RUNTIME_DIR'), 'wayland-0')) 35 | 36 | _has_Gtk = (3 if check_version('Gtk', '3.0') else 37 | 2 if check_version('Gtk', '2.0') else 38 | 0) 39 | 40 | _has_Notify = check_version('Notify', '0.7') 41 | _has_AppIndicator3 = ( 42 | check_version('AyatanaAppIndicator3', '0.1') or 43 | check_version('AppIndicator3', '0.1') 44 | ) 45 | 46 | 47 | def require_Gtk(min_version=2): 48 | """ 49 | Make sure Gtk is properly initialized. 50 | 51 | :raises RuntimeError: if Gtk can not be properly initialized 52 | """ 53 | if not (_in_X or _in_Wayland): 54 | raise RuntimeError('Not in X or Wayland session.') 55 | if _has_Gtk < min_version: 56 | raise RuntimeError('Module gi.repository.Gtk not available!') 57 | if _has_Gtk == 2: 58 | logging.getLogger(__name__).warn( 59 | _("Missing runtime dependency GTK 3. Falling back to GTK 2 " 60 | "for password prompt")) 61 | from gi.repository import Gtk 62 | # if we attempt to create any GUI elements with no X server running the 63 | # program will just crash, so let's make a way to catch this case: 64 | if not Gtk.init_check(None)[0]: 65 | raise RuntimeError(_("X server not connected!")) 66 | return Gtk 67 | 68 | 69 | def require_Notify(): 70 | if not _has_Notify: 71 | raise RuntimeError('Module gi.repository.Notify not available!') 72 | from gi.repository import Notify 73 | return Notify 74 | 75 | 76 | def require_AppIndicator3(): 77 | if _has_AppIndicator3 == ('AppIndicator3', '0.1'): 78 | from gi.repository import AppIndicator3 79 | elif _has_AppIndicator3 == ('AyatanaAppIndicator3', '0.1'): 80 | from gi.repository import AyatanaAppIndicator3 as AppIndicator3 81 | else: 82 | raise RuntimeError('Module gi.repository.AppIndicator3 not available!') 83 | return AppIndicator3 84 | 85 | 86 | def has_Notify(): 87 | return check_call((RuntimeError, ImportError), require_Notify) 88 | 89 | 90 | def has_Gtk(min_version=2): 91 | return check_call((RuntimeError, ImportError), require_Gtk, min_version) 92 | 93 | 94 | def has_AppIndicator3(): 95 | return check_call((RuntimeError, ImportError), require_AppIndicator3) 96 | -------------------------------------------------------------------------------- /udiskie/icons/__init__.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | try: 4 | import importlib.resources as resources 5 | except ImportError: # for Python<3.7 6 | import importlib_resources as resources 7 | 8 | 9 | class IconDist: 10 | 11 | def __init__(self): 12 | self._context = contextlib.ExitStack() 13 | self._paths = {} 14 | 15 | def patch_list(self, icons): 16 | return [ 17 | path 18 | for icon in icons 19 | for path in [icon, self.lookup(icon)] 20 | if path 21 | ] 22 | 23 | def lookup(self, name): 24 | if name in self._paths: 25 | return self._paths[name] 26 | try: 27 | path = self._context.enter_context( 28 | resources.path('udiskie.icons', name + '.svg')) 29 | path = str(path.absolute()) 30 | except FileNotFoundError: 31 | path = None 32 | self._paths[name] = path 33 | return path 34 | -------------------------------------------------------------------------------- /udiskie/icons/udiskie-checkbox-checked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 22 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /udiskie/icons/udiskie-checkbox-unchecked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /udiskie/icons/udiskie-detach.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 23 | 27 | 31 | 32 | 34 | 38 | 42 | 43 | 50 | 60 | 70 | 72 | 76 | 80 | 81 | 91 | 101 | 102 | 124 | 127 | 128 | 130 | 131 | 133 | image/svg+xml 134 | 136 | 137 | 138 | 139 | 140 | 144 | 152 | 158 | 159 | 160 | -------------------------------------------------------------------------------- /udiskie/icons/udiskie-eject.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 23 | 27 | 31 | 32 | 41 | 50 | 59 | 61 | 65 | 69 | 70 | 79 | 88 | 90 | 94 | 98 | 99 | 109 | 118 | 120 | 124 | 128 | 129 | 138 | 148 | 150 | 154 | 158 | 159 | 169 | 178 | 180 | 184 | 188 | 189 | 199 | 209 | 211 | 215 | 219 | 220 | 230 | 239 | 248 | 258 | 268 | 269 | 289 | 292 | 293 | 295 | 296 | 298 | image/svg+xml 299 | 301 | 302 | 303 | 304 | 305 | 309 | 315 | 321 | 326 | 331 | 332 | 333 | -------------------------------------------------------------------------------- /udiskie/icons/udiskie-lock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 27 | 40 | 42 | 46 | 50 | 51 | 53 | 57 | 61 | 62 | 72 | 81 | 83 | 87 | 91 | 95 | 99 | 100 | 110 | 120 | 122 | 126 | 130 | 134 | 138 | 139 | 149 | 158 | 160 | 164 | 168 | 169 | 179 | 181 | 185 | 189 | 190 | 201 | 212 | 223 | 234 | 245 | 247 | 251 | 255 | 256 | 267 | 268 | 287 | 289 | 290 | 292 | image/svg+xml 293 | 295 | 296 | 297 | 298 | 299 | 303 | 310 | 318 | 326 | 333 | 339 | 345 | 351 | 352 | 353 | -------------------------------------------------------------------------------- /udiskie/icons/udiskie-submenu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 17 | 18 | 19 | 24 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /udiskie/icons/udiskie-unlock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 27 | 40 | 42 | 46 | 50 | 51 | 53 | 57 | 61 | 62 | 72 | 81 | 83 | 87 | 91 | 95 | 99 | 100 | 110 | 120 | 122 | 126 | 130 | 134 | 138 | 139 | 149 | 158 | 160 | 164 | 168 | 169 | 179 | 181 | 185 | 189 | 190 | 201 | 212 | 223 | 234 | 245 | 247 | 251 | 255 | 256 | 267 | 268 | 287 | 289 | 290 | 292 | image/svg+xml 293 | 295 | 296 | 297 | 298 | 299 | 303 | 310 | 318 | 326 | 333 | 339 | 345 | 351 | 352 | 353 | -------------------------------------------------------------------------------- /udiskie/locale.py: -------------------------------------------------------------------------------- 1 | """ 2 | I18n utilities. 3 | """ 4 | 5 | import os 6 | import sys 7 | from gettext import translation 8 | 9 | 10 | testdirs = [ 11 | # manual override: 12 | os.environ.get('TEXTDOMAINDIR'), 13 | # editable installation: 14 | os.path.join(os.path.dirname(__file__), '../build/locale'), 15 | # user or virtualenv installation: 16 | os.path.join(sys.prefix, 'share/locale'), 17 | ] 18 | testfile = 'en_US/LC_MESSAGES/udiskie.mo' 19 | localedir = next( 20 | (d for d in testdirs if d and os.path.exists(os.path.join(d, testfile))), 21 | None) 22 | 23 | _t = translation('udiskie', localedir, languages=None, fallback=True) 24 | 25 | 26 | def _(text, *args, **kwargs): 27 | """Translate and then and format the text with ``str.format``.""" 28 | msg = _t.gettext(text) 29 | if args or kwargs: 30 | return msg.format(*args, **kwargs) 31 | else: 32 | return msg 33 | -------------------------------------------------------------------------------- /udiskie/notify.py: -------------------------------------------------------------------------------- 1 | """ 2 | Notification utility. 3 | """ 4 | 5 | import logging 6 | 7 | from gi.repository import GLib 8 | 9 | from .async_ import run_bg 10 | from .common import exc_message, DaemonBase, format_exc 11 | from .mount import DeviceActions 12 | from .locale import _ 13 | 14 | 15 | __all__ = ['Notify'] 16 | 17 | 18 | class Notify(DaemonBase): 19 | 20 | """ 21 | Notification tool. 22 | 23 | Can be connected to udisks daemon in order to automatically issue 24 | notifications when system status has changed. 25 | 26 | NOTE: the action buttons in the notifications don't work with all 27 | notification services. 28 | """ 29 | 30 | EVENTS = ['device_mounted', 'device_unmounted', 31 | 'device_locked', 'device_unlocked', 32 | 'device_added', 'device_removed', 33 | 'job_failed'] 34 | 35 | def __init__(self, notify, mounter, timeout=None, aconfig=None): 36 | """ 37 | Initialize notifier and connect to service. 38 | 39 | :param notify: notification service module (gi.repository.Notify) 40 | :param mounter: Mounter object 41 | :param dict timeout: dictionary with timeouts for notifications 42 | """ 43 | self._notify = notify 44 | self._mounter = mounter 45 | self._actions = DeviceActions(mounter) 46 | self._timeout = timeout or {} 47 | self._aconfig = aconfig or {} 48 | self._default = self._timeout.get('timeout', -1) 49 | self._log = logging.getLogger(__name__) 50 | self._notifications = [] 51 | self.events = { 52 | event: getattr(self, event) 53 | for event in self.EVENTS 54 | if self._enabled(event) 55 | } 56 | 57 | # event handlers: 58 | def device_mounted(self, device): 59 | """Show mount notification for specified device object.""" 60 | if not self._mounter.is_handleable(device): 61 | return 62 | browse_action = (device, 'browse', _('Browse directory'), 63 | self._mounter.browse, device) 64 | terminal_action = (device, 'terminal', _('Open terminal'), 65 | self._mounter.terminal, device) 66 | self._show_notification( 67 | 'device_mounted', 68 | _('Device mounted'), 69 | _('{0.ui_label} mounted on {0.mount_paths[0]}', device), 70 | device.icon_name, 71 | self._mounter._browser and browse_action, 72 | self._mounter._terminal and terminal_action) 73 | 74 | def device_unmounted(self, device): 75 | """Show unmount notification for specified device object.""" 76 | if not self._mounter.is_handleable(device): 77 | return 78 | self._show_notification( 79 | 'device_unmounted', 80 | _('Device unmounted'), 81 | _('{0.ui_label} unmounted', device), 82 | device.icon_name) 83 | 84 | def device_locked(self, device): 85 | """Show lock notification for specified device object.""" 86 | if not self._mounter.is_handleable(device): 87 | return 88 | self._show_notification( 89 | 'device_locked', 90 | _('Device locked'), 91 | _('{0.device_presentation} locked', device), 92 | device.icon_name) 93 | 94 | def device_unlocked(self, device): 95 | """Show unlock notification for specified device object.""" 96 | if not self._mounter.is_handleable(device): 97 | return 98 | self._show_notification( 99 | 'device_unlocked', 100 | _('Device unlocked'), 101 | _('{0.device_presentation} unlocked', device), 102 | device.icon_name) 103 | 104 | def device_added(self, device): 105 | """Show discovery notification for specified device object.""" 106 | if not self._mounter.is_handleable(device): 107 | return 108 | if self._has_actions('device_added'): 109 | # wait for partitions etc to be reported to udiskie, otherwise we 110 | # can't discover the actions 111 | GLib.timeout_add(500, self._device_added, device) 112 | else: 113 | self._device_added(device) 114 | 115 | def _device_added(self, device): 116 | device_file = device.device_presentation 117 | if (device.is_drive or device.is_toplevel) and device_file: 118 | # On UDisks1: cannot invoke self._actions.detect() for newly added 119 | # LUKS devices. It should be okay if we had waited for the actions 120 | # to be added, though. 121 | if self._has_actions('device_added'): 122 | node_tree = self._actions.detect(device.object_path) 123 | flat_actions = self._flatten_node(node_tree) 124 | actions = [ 125 | (action.device, 126 | action.method, 127 | action.label.format(action.device.ui_label), 128 | action.action) 129 | for action in flat_actions 130 | ] 131 | else: 132 | actions = () 133 | self._show_notification( 134 | 'device_added', 135 | _('Device added'), 136 | _('device appeared on {0.device_presentation}', device), 137 | device.icon_name, 138 | *actions) 139 | 140 | def _flatten_node(self, node): 141 | actions = [action 142 | for branch in node.branches 143 | for action in self._flatten_node(branch)] 144 | actions += node.methods 145 | return actions 146 | 147 | def device_removed(self, device): 148 | """Show removal notification for specified device object.""" 149 | if not self._mounter.is_handleable(device): 150 | return 151 | device_file = device.device_presentation 152 | if (device.is_drive or device.is_toplevel) and device_file: 153 | self._show_notification( 154 | 'device_removed', 155 | _('Device removed'), 156 | _('device disappeared on {0.device_presentation}', device), 157 | device.icon_name) 158 | 159 | def job_failed(self, device, action, message): 160 | """Show 'Job failed' notification with 'Retry' button.""" 161 | if not self._mounter.is_handleable(device): 162 | return 163 | device_file = device.device_presentation or device.object_path 164 | if message: 165 | text = _('failed to {0} {1}:\n{2}', action, device_file, message) 166 | else: 167 | text = _('failed to {0} device {1}.', action, device_file) 168 | try: 169 | retry = getattr(self._mounter, action) 170 | except AttributeError: 171 | retry_action = None 172 | else: 173 | retry_action = (device, 'retry', _('Retry'), retry, device) 174 | self._show_notification( 175 | 'job_failed', 176 | _('Job failed'), text, 177 | device.icon_name, 178 | retry_action) 179 | 180 | def _show_notification(self, 181 | event, summary, message, icon, 182 | *actions): 183 | """ 184 | Show a notification. 185 | 186 | :param str event: event name 187 | :param str summary: notification title 188 | :param str message: notification body 189 | :param str icon: icon name 190 | :param actions: each item is a tuple with parameters for _add_action 191 | """ 192 | notification = self._notify(summary, message, icon) 193 | timeout = self._get_timeout(event) 194 | if timeout != -1: 195 | notification.set_timeout(int(timeout * 1000)) 196 | for action in actions: 197 | if action and self._action_enabled(event, action[1]): 198 | self._add_action(notification, *action) 199 | try: 200 | notification.show() 201 | except GLib.GError as exc: 202 | # Catch and log the exception. Starting udiskie with notifications 203 | # enabled while not having a notification service installed is a 204 | # mistake too easy to be made, but it should not render the rest of 205 | # udiskie's logic useless by raising an exception before the 206 | # automount handler gets invoked. 207 | self._log.error(_("Failed to show notification: {0}", exc_message(exc))) 208 | self._log.debug(format_exc()) 209 | 210 | def _add_action(self, notification, device, action, label, callback, *args): 211 | """ 212 | Show an action button button in mount notifications. 213 | 214 | Note, this only works with some libnotify services. 215 | """ 216 | action = action + ':' + device.device_file 217 | on_action_click = run_bg(lambda *_: callback(*args)) 218 | try: 219 | # this is the correct signature for Notify-0.7, the last argument 220 | # being 'user_data': 221 | notification.add_action(action, label, on_action_click, None) 222 | except TypeError: 223 | # this is the signature for some older version, I don't know what 224 | # the last argument is for. 225 | notification.add_action(action, label, on_action_click, None, None) 226 | # gi.Notify does not store hard references to the notification 227 | # objects. When a signal is received and the notification does not 228 | # exist anymore, no handler will be called. Therefore, we need to 229 | # prevent these notifications from being destroyed by storing 230 | # references: 231 | notification.connect('closed', self._notifications.remove) 232 | self._notifications.append(notification) 233 | 234 | def _enabled(self, event): 235 | """Check if the notification for an event is enabled.""" 236 | return self._get_timeout(event) not in (None, False) 237 | 238 | def _get_timeout(self, event): 239 | """Get the timeout for an event from the config or None.""" 240 | return self._timeout.get(event, self._default) 241 | 242 | def _action_enabled(self, event, action): 243 | """Check if an action for a notification is enabled.""" 244 | event_actions = self._aconfig.get(event) 245 | if event_actions is None: 246 | return True 247 | if event_actions is False: 248 | return False 249 | return action in event_actions 250 | 251 | def _has_actions(self, event): 252 | """Check if a notification type has any enabled actions.""" 253 | event_actions = self._aconfig.get(event) 254 | return event_actions is None or bool(event_actions) 255 | -------------------------------------------------------------------------------- /udiskie/password_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 4 | center 5 | dialog 6 | password-dialog 7 | 8 | 9 | 6 10 | 6 11 | True 12 | 13 | 14 | 0 15 | True 16 | 17 | 18 | 19 | 20 | False 21 | True 22 | True 23 | 24 | 25 | 26 | 27 | Show password 28 | False 29 | True 30 | 31 | 32 | 33 | 34 | Remember password 35 | False 36 | 37 | 38 | 39 | 40 | True 41 | 42 | 43 | gtk-cancel 44 | True 45 | True 46 | 47 | 48 | 49 | 50 | gtk-ok 51 | True 52 | True 53 | True 54 | True 55 | 56 | 57 | 58 | 59 | Open keyfile… 60 | False 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | cancel_button 69 | ok_button 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /udiskie/prompt.py: -------------------------------------------------------------------------------- 1 | """ 2 | User prompt utility. 3 | """ 4 | 5 | from udiskie.depend import has_Gtk, require_Gtk 6 | from udiskie.common import is_utf8 7 | 8 | from shutil import which 9 | import getpass 10 | import logging 11 | import shlex 12 | import string 13 | import subprocess 14 | import sys 15 | 16 | try: 17 | from importlib.resources import read_text 18 | except ImportError: # for Python<3.7 19 | from importlib_resources import read_text 20 | 21 | from .async_ import exec_subprocess, run_bg, Future 22 | from .locale import _ 23 | from .config import DeviceFilter 24 | 25 | Gtk = None 26 | 27 | __all__ = ['password', 'browser'] 28 | 29 | 30 | dialog_definition = read_text(__package__, 'password_dialog.ui') 31 | 32 | 33 | class Dialog(Future): 34 | 35 | def __init__(self, window): 36 | self._enter_count = 0 37 | self.window = window 38 | self.window.connect("response", self._result_handler) 39 | 40 | def _result_handler(self, window, response): 41 | self.set_result(response) 42 | 43 | def __enter__(self): 44 | self._enter_count += 1 45 | self._awaken() 46 | return self 47 | 48 | def __exit__(self, *exc_info): 49 | self._enter_count -= 1 50 | if self._enter_count == 0: 51 | self._cleanup() 52 | 53 | def _awaken(self): 54 | self.window.present() 55 | 56 | def _cleanup(self): 57 | self.window.hide() 58 | self.window.destroy() 59 | 60 | 61 | class PasswordResult: 62 | def __init__(self, password=None, cache_hint=None): 63 | self.password = password 64 | self.cache_hint = cache_hint 65 | 66 | 67 | class PasswordDialog(Dialog): 68 | 69 | INSTANCES = {} 70 | content = None 71 | 72 | @classmethod 73 | def create(cls, key, title, message, options): 74 | if key in cls.INSTANCES: 75 | return cls.INSTANCES[key] 76 | return cls(key, title, message, options) 77 | 78 | def _awaken(self): 79 | self.INSTANCES[self.key] = self 80 | super()._awaken() 81 | 82 | def _cleanup(self): 83 | del self.INSTANCES[self.key] 84 | super()._cleanup() 85 | 86 | def __init__(self, key, title, message, options): 87 | self.key = key 88 | global Gtk 89 | Gtk = require_Gtk() 90 | builder = Gtk.Builder.new() 91 | builder.add_from_string(dialog_definition) 92 | window = builder.get_object('entry_dialog') 93 | self.entry = builder.get_object('entry') 94 | 95 | show_password = builder.get_object('show_password') 96 | show_password.set_label(_('Show password')) 97 | show_password.connect('clicked', self.on_show_password) 98 | 99 | allow_keyfile = options.get('allow_keyfile') 100 | keyfile_button = builder.get_object('keyfile_button') 101 | keyfile_button.set_label(_('Open keyfile…')) 102 | keyfile_button.set_visible(allow_keyfile) 103 | keyfile_button.connect('clicked', run_bg(self.on_open_keyfile)) 104 | 105 | allow_cache = options.get('allow_cache') 106 | cache_hint = options.get('cache_hint') 107 | self.use_cache = builder.get_object('remember') 108 | self.use_cache.set_label(_('Cache password')) 109 | self.use_cache.set_visible(allow_cache) 110 | self.use_cache.set_active(cache_hint) 111 | 112 | label = builder.get_object('message') 113 | label.set_label(message) 114 | window.set_title(title) 115 | window.set_keep_above(True) 116 | super().__init__(window) 117 | 118 | def on_show_password(self, button): 119 | self.entry.set_visibility(button.get_active()) 120 | 121 | async def on_open_keyfile(self, button): 122 | gtk_dialog = Gtk.FileChooserDialog( 123 | _("Open a keyfile to unlock the LUKS device"), self.window, 124 | Gtk.FileChooserAction.OPEN, 125 | (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, 126 | Gtk.STOCK_OPEN, Gtk.ResponseType.OK)) 127 | with Dialog(gtk_dialog) as dialog: 128 | response = await dialog 129 | if response == Gtk.ResponseType.OK: 130 | with open(dialog.window.get_filename(), 'rb') as f: 131 | self.content = f.read() 132 | self.window.response(response) 133 | 134 | def get_text(self): 135 | if self.content is not None: 136 | return self.content 137 | return self.entry.get_text() 138 | 139 | 140 | async def password_dialog(key, title, message, options): 141 | """ 142 | Show a Gtk password dialog. 143 | 144 | :returns: the password or ``None`` if the user aborted the operation 145 | :raises RuntimeError: if Gtk can not be properly initialized 146 | """ 147 | with PasswordDialog.create(key, title, message, options) as dialog: 148 | response = await dialog 149 | if response == Gtk.ResponseType.OK: 150 | return PasswordResult(dialog.get_text(), 151 | dialog.use_cache.get_active()) 152 | return None 153 | 154 | 155 | def get_password_gui(device, options): 156 | """Get the password to unlock a device from GUI.""" 157 | text = _('Enter password for {0.device_presentation}: ', device) 158 | try: 159 | return password_dialog(device.id_uuid, 'udiskie', text, options) 160 | except RuntimeError: 161 | return None 162 | 163 | 164 | async def get_password_tty(device, options): 165 | """Get the password to unlock a device from terminal.""" 166 | # TODO: make this a TRUE async 167 | text = _('Enter password for {0.device_presentation}: ', device) 168 | try: 169 | return PasswordResult(getpass.getpass(text)) 170 | except EOFError: 171 | print("") 172 | return None 173 | 174 | 175 | class DeviceCommand: 176 | 177 | """ 178 | Launcher that starts user-defined password prompts. The command can be 179 | specified in terms of a command line template. 180 | """ 181 | 182 | def __init__(self, argv, capture=False, **extra): 183 | """Create the launcher object from the command line template.""" 184 | if isinstance(argv, str): 185 | self.argv = shlex.split(argv) 186 | else: 187 | self.argv = argv 188 | self.capture = capture 189 | self.extra = extra.copy() 190 | # obtain a list of used fields names 191 | formatter = string.Formatter() 192 | self.used_attrs = set() 193 | for arg in self.argv: 194 | for text, kwd, spec, conv in formatter.parse(arg): 195 | if kwd is None: 196 | continue 197 | if kwd in DeviceFilter.VALID_PARAMETERS: 198 | self.used_attrs.add(kwd) 199 | if kwd not in DeviceFilter.VALID_PARAMETERS and \ 200 | kwd not in self.extra: 201 | self.extra[kwd] = None 202 | logging.getLogger(__name__).error(_( 203 | 'Unknown device attribute {!r} in format string: {!r}', 204 | kwd, arg)) 205 | 206 | async def __call__(self, device): 207 | """ 208 | Invoke the subprocess to ask the user to enter a password for unlocking 209 | the specified device. 210 | """ 211 | attrs = {attr: getattr(device, attr) for attr in self.used_attrs} 212 | attrs.update(self.extra) 213 | argv = [arg.format(**attrs) for arg in self.argv] 214 | try: 215 | stdout = await exec_subprocess(argv, self.capture) 216 | except subprocess.CalledProcessError: 217 | return None 218 | # Remove trailing newline for text answers, but not for binary 219 | # keyfiles. This logic is a guess that may cause bugs for some users:( 220 | if stdout and stdout.endswith(b'\n') and is_utf8(stdout): 221 | stdout = stdout[:-1] 222 | return stdout 223 | 224 | async def password(self, device, options): 225 | text = await self(device) 226 | return PasswordResult(text) 227 | 228 | 229 | def password(password_command): 230 | """Create a password prompt function.""" 231 | gui = lambda: has_Gtk() and get_password_gui 232 | tty = lambda: sys.stdin.isatty() and get_password_tty 233 | if password_command == 'builtin:gui': 234 | return gui() or tty() 235 | elif password_command == 'builtin:tty': 236 | return tty() or gui() 237 | elif password_command: 238 | return DeviceCommand(password_command, capture=True).password 239 | else: 240 | return None 241 | 242 | 243 | def browser(browser_name='xdg-open'): 244 | 245 | """Create a browse-directory function.""" 246 | 247 | if not browser_name: 248 | return None 249 | argv = shlex.split(browser_name) 250 | executable = which(argv[0]) 251 | if executable is None: 252 | # Why not raise an exception? -I think it is more convenient (for 253 | # end users) to have a reasonable default, without enforcing it. 254 | logging.getLogger(__name__).warn( 255 | _("Can't find file browser: {0!r}. " 256 | "You may want to change the value for the '-f' option.", 257 | browser_name)) 258 | return None 259 | 260 | def browse(path): 261 | return subprocess.Popen(argv + [path]) 262 | 263 | return browse 264 | 265 | 266 | def connect_event_hook(command_format, mounter): 267 | """ 268 | Command notification tool. 269 | 270 | This works similar to Notify, but will issue command instead of showing 271 | the notifications on the desktop. This can then be used to react to events 272 | from shell scripts. 273 | 274 | The command can contain modern pythonic format placeholders like: 275 | {device_file}. The following placeholders are supported: 276 | event, device_file, device_id, device_size, drive, drive_label, id_label, 277 | id_type, id_usage, id_uuid, mount_path, root 278 | 279 | :param str command_format: command to run when an event occurs. 280 | :param mounter: Mounter object 281 | """ 282 | udisks = mounter.udisks 283 | for event in ['device_mounted', 'device_unmounted', 284 | 'device_locked', 'device_unlocked', 285 | 'device_added', 'device_removed', 286 | 'job_failed']: 287 | udisks.connect(event, run_bg(DeviceCommand( 288 | command_format, event=event, capture=False))) 289 | -------------------------------------------------------------------------------- /udiskie/tray.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tray icon for udiskie. 3 | """ 4 | 5 | from gi.repository import Gio 6 | from gi.repository import Gtk 7 | 8 | from .async_ import run_bg, Future 9 | from .common import setdefault, DaemonBase, cachedmethod 10 | from .locale import _ 11 | from .mount import Action, prune_empty_node 12 | from .prompt import Dialog 13 | from .icons import IconDist 14 | 15 | import os 16 | 17 | 18 | __all__ = ['TrayMenu', 'TrayIcon'] 19 | 20 | 21 | class MenuFolder: 22 | 23 | def __init__(self, label, items): 24 | self.label = label 25 | self.items = items 26 | 27 | def __bool__(self): 28 | return bool(self.items) 29 | 30 | __nonzero__ = __bool__ 31 | 32 | 33 | class MenuSection(MenuFolder): 34 | pass 35 | 36 | 37 | class SubMenu(MenuFolder): 38 | pass 39 | 40 | 41 | class Icons: 42 | 43 | """Encapsulates the responsibility to load icons.""" 44 | 45 | _icon_names = { 46 | 'media': [ 47 | 'drive-removable-media-usb-panel', 48 | 'drive-removable-media-usb-pendrive', 49 | 'drive-removable-media-usb', 50 | 'drive-removable-media', 51 | 'media-optical', 52 | 'media-flash', 53 | ], 54 | 'browse': ['document-open', 'folder-open'], 55 | 'terminal': ['terminal', 'utilities-terminal'], 56 | 'mount': ['udiskie-mount'], 57 | 'unmount': ['udiskie-unmount'], 58 | 'unlock': ['udiskie-unlock'], 59 | 'lock': ['udiskie-lock'], 60 | 'eject': ['udiskie-eject', 'media-eject'], 61 | 'detach': ['udiskie-detach'], 62 | 'quit': ['application-exit'], 63 | 'forget_password': ['edit-delete'], 64 | 'delete': ['udiskie-eject'], 65 | 'losetup': ['udiskie-mount'], 66 | # checkbox workaround: 67 | 'checked': ['checkbox-checked', 'udiskie-checkbox-checked'], 68 | 'unchecked': ['checkbox', 'udiskie-checkbox-unchecked'], 69 | 'submenu': ['udiskie-submenu', 'pan-end-symbolic'], 70 | } 71 | 72 | def __init__(self, icon_names={}): 73 | """Merge ``icon_names`` into default icon names.""" 74 | self._icon_dist = IconDist() 75 | _icon_names = icon_names.copy() 76 | setdefault(_icon_names, self.__class__._icon_names) 77 | self._icon_names = _icon_names 78 | for k, v in _icon_names.items(): 79 | if isinstance(v, str): 80 | self._icon_names[k] = v = [v] 81 | self._icon_names[k] = self._icon_dist.patch_list(v) 82 | 83 | @cachedmethod 84 | def get_icon_name(self, icon_id: str) -> str: 85 | """Lookup the system icon name from udisie-internal id.""" 86 | icon_theme = Gtk.IconTheme.get_default() 87 | for name in self._icon_names[icon_id]: 88 | if icon_theme.has_icon(name): 89 | return name 90 | elif os.path.exists(name): 91 | return name 92 | return 'not-available' 93 | 94 | def get_icon(self, icon_id: str, size: "Gtk.IconSize") -> "Gtk.Image": 95 | """Load Gtk.Image from udiskie-internal id.""" 96 | return Gtk.Image.new_from_gicon(self.get_gicon(icon_id), size) 97 | 98 | def get_gicon(self, icon_id: str) -> "Gio.Icon": 99 | """Lookup Gio.Icon from udiskie-internal id.""" 100 | name = self.get_icon_name(icon_id) 101 | if os.path.exists(name): 102 | # TODO (?): we could also add the icon to the theme using 103 | # Gtk.IconTheme.append_search_path or .add_resource_path: 104 | file = Gio.File.new_for_path(name) 105 | return Gio.FileIcon.new(file) 106 | else: 107 | return Gio.ThemedIcon.new(name) 108 | 109 | 110 | class TrayMenu: 111 | 112 | """ 113 | Builder for udiskie menus. 114 | 115 | Objects of this class generate action menus when being called. 116 | """ 117 | 118 | def __init__(self, daemon, icons, actions, flat=True, 119 | quickmenu_actions=None, 120 | checkbox_workaround=False, 121 | update_workaround=False): 122 | """ 123 | Initialize a new menu maker. 124 | 125 | :param object mounter: mount operation provider 126 | :param Icons icons: icon provider 127 | :param DeviceActions actions: device actions discovery 128 | :returns: a new menu maker 129 | :rtype: cls 130 | 131 | Required keys for the ``_labels``, ``_menu_icons`` and 132 | ``actions`` dictionaries are: 133 | 134 | - browse Open mount location 135 | - terminal Open mount location in terminal 136 | - mount Mount a device 137 | - unmount Unmount a device 138 | - unlock Unlock a LUKS device 139 | - lock Lock a LUKS device 140 | - eject Eject a drive 141 | - detach Detach (power down) a drive 142 | - quit Exit the application 143 | 144 | NOTE: If using a main loop other than ``Gtk.main`` the 'quit' action 145 | must be customized. 146 | """ 147 | self._icons = icons 148 | self._daemon = daemon 149 | self._mounter = daemon.mounter 150 | self._actions = actions 151 | self._quit_action = daemon.mainloop.quit 152 | self.flat = flat 153 | # actions shown in the quick-menu ("flat", left-click): 154 | self._quickmenu_actions = quickmenu_actions or [ 155 | 'mount', 156 | 'browse', 157 | 'terminal', 158 | 'unlock', 159 | 'detach', 160 | 'delete', 161 | # suppressed: 162 | # 'unmount', 163 | # 'lock', 164 | # 'eject', 165 | # 'forget_password', 166 | ] 167 | self._checkbox_workaround = checkbox_workaround 168 | self._update_workaround = update_workaround 169 | 170 | def __call__(self, menu, extended=True): 171 | """Populate the Gtk.Menu with udiskie mount operations.""" 172 | # create actions items 173 | flat = self.flat and not extended 174 | if self._update_workaround: 175 | # When showing menus via AppIndicator3 on sway, the menu geometry 176 | # seems to be calculated before the 'about-to-show' event, and 177 | # therefore cannot take into account newly inserted menu items. 178 | # For this reason, we have to keep the top-level menu fixed-size 179 | # and insert dynamic entries into a submenu. 180 | devmenu = Gtk.Menu() 181 | menu.append(self._menuitem( 182 | label=_('Managed devices'), 183 | icon=None, 184 | onclick=devmenu, 185 | )) 186 | else: 187 | devmenu = menu 188 | self._create_menu_items( 189 | devmenu, self._prepare_menu(self.detect(), flat)) 190 | if extended: 191 | self._insert_options(menu) 192 | return menu 193 | 194 | def _insert_options(self, menu): 195 | """Add configuration options to menu.""" 196 | menu.append(Gtk.SeparatorMenuItem()) 197 | menu.append(self._menuitem( 198 | _('Mount disc image'), 199 | self._icons.get_icon('losetup', Gtk.IconSize.MENU), 200 | run_bg(lambda _: self._losetup()) 201 | )) 202 | menu.append(Gtk.SeparatorMenuItem()) 203 | menu.append(self._menuitem( 204 | _("Enable automounting"), 205 | icon=None, 206 | onclick=lambda _: self._daemon.automounter.toggle_on(), 207 | checked=self._daemon.automounter.is_on(), 208 | )) 209 | menu.append(self._menuitem( 210 | _("Enable notifications"), 211 | icon=None, 212 | onclick=lambda _: self._daemon.notify.toggle(), 213 | checked=self._daemon.notify.active, 214 | )) 215 | # append menu item for closing the application 216 | if self._quit_action: 217 | menu.append(Gtk.SeparatorMenuItem()) 218 | menu.append(self._menuitem( 219 | _('Quit'), 220 | self._icons.get_icon('quit', Gtk.IconSize.MENU), 221 | lambda _: self._quit_action() 222 | )) 223 | 224 | async def _losetup(self): 225 | gtk_dialog = Gtk.FileChooserDialog( 226 | _('Open disc image'), None, 227 | Gtk.FileChooserAction.OPEN, 228 | (_('Open'), Gtk.ResponseType.OK, 229 | _('Cancel'), Gtk.ResponseType.CANCEL)) 230 | with Dialog(gtk_dialog) as dialog: 231 | response = await dialog 232 | if response == Gtk.ResponseType.OK: 233 | await self._mounter.losetup(dialog.window.get_filename()) 234 | 235 | def detect(self): 236 | """Detect all currently known devices. Returns the root device.""" 237 | root = self._actions.detect() 238 | prune_empty_node(root, set()) 239 | return root 240 | 241 | def _create_menu(self, items): 242 | """ 243 | Create a menu from the given node. 244 | 245 | :param list items: list of menu items 246 | :returns: a new Gtk.Menu object holding all items of the node 247 | """ 248 | menu = Gtk.Menu() 249 | self._create_menu_items(menu, items) 250 | return menu 251 | 252 | def _create_menu_items(self, menu, items): 253 | def make_action_callback(node): 254 | return run_bg(lambda _: node.action()) 255 | for node in items: 256 | if isinstance(node, Action): 257 | menu.append(self._menuitem( 258 | node.label, 259 | self._icons.get_icon(node.method, Gtk.IconSize.MENU), 260 | make_action_callback(node))) 261 | elif isinstance(node, SubMenu): 262 | menu.append(self._menuitem( 263 | node.label, 264 | icon=None, 265 | onclick=self._create_menu(node.items))) 266 | elif isinstance(node, MenuSection): 267 | self._create_menu_section(menu, node) 268 | else: 269 | raise ValueError(_("Invalid node!")) 270 | if len(menu) == 0: 271 | mi = self._menuitem(_("No external devices"), None, None) 272 | mi.set_sensitive(False) 273 | menu.append(mi) 274 | 275 | def _create_menu_section(self, menu, section): 276 | if len(menu) > 0: 277 | menu.append(Gtk.SeparatorMenuItem()) 278 | if section.label: 279 | mi = self._menuitem(section.label, None, None) 280 | mi.set_sensitive(False) 281 | menu.append(mi) 282 | self._create_menu_items(menu, section.items) 283 | 284 | def _menuitem(self, label, icon, onclick, checked=None): 285 | """ 286 | Create a generic menu item. 287 | 288 | :param str label: text 289 | :param Gtk.Image icon: icon (may be ``None``) 290 | :param onclick: onclick handler, either a callable or Gtk.Menu 291 | :returns: the menu item object 292 | :rtype: Gtk.MenuItem 293 | """ 294 | if self._checkbox_workaround: 295 | if checked is not None: 296 | icon_name = 'checked' if checked else 'unchecked' 297 | icon = self._icons.get_icon(icon_name, Gtk.IconSize.MENU) 298 | checked = None 299 | elif isinstance(onclick, Gtk.Menu): 300 | icon = self._icons.get_icon('submenu', Gtk.IconSize.MENU) 301 | if checked is not None: 302 | item = Gtk.CheckMenuItem() 303 | item.set_active(checked) 304 | elif icon is None: 305 | item = Gtk.MenuItem() 306 | else: 307 | item = Gtk.ImageMenuItem() 308 | item.set_image(icon) 309 | # I don't really care for the "show icons only for nouns, not 310 | # for verbs" policy: 311 | item.set_always_show_image(True) 312 | if label is not None: 313 | item.set_label(label) 314 | if isinstance(onclick, Gtk.Menu): 315 | item.set_submenu(onclick) 316 | elif onclick is not None: 317 | item.connect('activate', onclick) 318 | return item 319 | 320 | def _prepare_menu(self, node, flat=None): 321 | """ 322 | Prepare the menu hierarchy from the given device tree. 323 | 324 | :param Device node: root node of device hierarchy 325 | :returns: menu hierarchy as list 326 | """ 327 | if flat is None: 328 | flat = self.flat 329 | ItemGroup = MenuSection if flat else SubMenu 330 | return [ 331 | ItemGroup(branch.label, self._collapse_device(branch, flat)) 332 | for branch in node.branches 333 | if branch.methods or branch.branches 334 | ] 335 | 336 | def _collapse_device(self, node, flat): 337 | """Collapse device hierarchy into a flat folder.""" 338 | items = [item 339 | for branch in node.branches 340 | for item in self._collapse_device(branch, flat) 341 | if item] 342 | show_all = not flat or self._quickmenu_actions == 'all' 343 | methods = node.methods if show_all else [ 344 | method 345 | for method in node.methods 346 | if method.method in self._quickmenu_actions 347 | ] 348 | if flat: 349 | items.extend(methods) 350 | else: 351 | items.append(MenuSection(None, methods)) 352 | return items 353 | 354 | 355 | class TrayIcon: 356 | 357 | """Default TrayIcon class.""" 358 | 359 | def __init__(self, menumaker, icons, statusicon=None): 360 | """ 361 | Create an object managing a tray icon. 362 | 363 | The actual Gtk.StatusIcon is only created as soon as you call show() 364 | for the first time. The reason to delay its creation is that the GTK 365 | icon will be initially visible, which results in a perceptable 366 | flickering. 367 | 368 | :param TrayMenu menumaker: menu factory 369 | :param Gtk.StatusIcon statusicon: status icon 370 | """ 371 | self._icons = icons 372 | self._icon = statusicon 373 | self._menu = menumaker 374 | self._conn_left = None 375 | self._conn_right = None 376 | self.task = Future() 377 | menumaker._quit_action = self.destroy 378 | 379 | def destroy(self): 380 | self.show(False) 381 | self.task.set_result(True) 382 | 383 | def _create_statusicon(self): 384 | """Return a new Gtk.StatusIcon.""" 385 | statusicon = Gtk.StatusIcon() 386 | statusicon.set_from_gicon(self._icons.get_gicon('media')) 387 | statusicon.set_tooltip_text(_("udiskie")) 388 | return statusicon 389 | 390 | @property 391 | def visible(self): 392 | """Return visibility state of icon.""" 393 | return bool(self._conn_left) 394 | 395 | def show(self, show=True): 396 | """Show or hide the tray icon.""" 397 | if show and not self.visible: 398 | self._show() 399 | if not show and self.visible: 400 | self._hide() 401 | 402 | def _show(self): 403 | """Show the tray icon.""" 404 | if not self._icon: 405 | self._icon = self._create_statusicon() 406 | widget = self._icon 407 | widget.set_visible(True) 408 | self._conn_left = widget.connect("activate", self._activate) 409 | self._conn_right = widget.connect("popup-menu", self._popup_menu) 410 | 411 | def _hide(self): 412 | """Hide the tray icon.""" 413 | self._icon.set_visible(False) 414 | self._icon.disconnect(self._conn_left) 415 | self._icon.disconnect(self._conn_right) 416 | self._conn_left = None 417 | self._conn_right = None 418 | 419 | def create_context_menu(self, extended): 420 | """Create the context menu.""" 421 | menu = Gtk.Menu() 422 | self._menu(menu, extended) 423 | return menu 424 | 425 | def _activate(self, icon): 426 | """Handle a left click event (show the menu).""" 427 | self._popup_menu(icon, button=0, time=Gtk.get_current_event_time(), 428 | extended=False) 429 | 430 | def _popup_menu(self, icon, button, time, extended=True): 431 | """Handle a right click event (show the menu).""" 432 | m = self.create_context_menu(extended) 433 | m.show_all() 434 | m.popup(parent_menu_shell=None, 435 | parent_menu_item=None, 436 | func=icon.position_menu, 437 | data=icon, 438 | button=button, 439 | activate_time=time) 440 | # need to store reference or menu will be destroyed before showing: 441 | self._m = m 442 | 443 | 444 | class UdiskieStatusIcon(DaemonBase): 445 | 446 | """ 447 | Manage a status icon. 448 | 449 | When `smart` is on, the icon will automatically hide if there is no action 450 | available and the menu will have no 'Quit' item. 451 | """ 452 | 453 | def __init__(self, icon, menumaker, smart=False): 454 | self._icon = icon 455 | self._menumaker = menumaker 456 | self._mounter = menumaker._mounter 457 | self._quit_action = menumaker._quit_action 458 | self.smart = smart 459 | self.active = False 460 | self.events = { 461 | 'device_changed': self.update, 462 | 'device_added': self.update, 463 | 'device_removed': self.update, 464 | } 465 | 466 | def activate(self): 467 | super().activate() 468 | self.update() 469 | 470 | def deactivate(self): 471 | super().deactivate() 472 | self._icon.show(False) 473 | 474 | @property 475 | def smart(self): 476 | return getattr(self, '_smart', None) 477 | 478 | @smart.setter 479 | def smart(self, smart): 480 | if smart == self.smart: 481 | return 482 | if smart: 483 | self._menumaker._quit_action = None 484 | else: 485 | self._menumaker._quit_action = self._quit_action 486 | self._smart = smart 487 | self.update() 488 | 489 | def has_menu(self): 490 | """Check if a menu action is available.""" 491 | return any(self._menumaker._prepare_menu(self._menumaker.detect())) 492 | 493 | def update(self, *args): 494 | """Show/hide icon depending on whether there are devices.""" 495 | if self.smart: 496 | self._icon.show(self.has_menu()) 497 | else: 498 | self._icon.show(True) 499 | --------------------------------------------------------------------------------