├── tuhi ├── __init__.py ├── gui │ ├── __init__.py │ ├── README.md │ ├── config.py │ ├── application.py │ ├── drawing.py │ ├── drawingperspective.py │ └── window.py ├── util.py ├── config.py ├── drawing.py ├── export.py ├── uhid.py └── dbusclient.py ├── po ├── meson.build ├── LINGUAS ├── POTFILES ├── README.md ├── tr.po ├── pl.po └── it.po ├── example.ini ├── .gitignore ├── meson_install.sh ├── data ├── org.freedesktop.Tuhi.desktop.in ├── org.freedesktop.Tuhi-symbolic.svg ├── tuhi.gresource.xml ├── ui │ ├── AppMenu.ui │ ├── AboutDialog.ui.in │ ├── Flowbox.ui │ ├── ErrorPerspective.ui │ ├── Drawing.ui │ ├── DrawingPerspective.ui │ ├── MainWindow.ui │ └── SetupPerspective.ui ├── meson.build ├── org.freedesktop.Tuhi.appdata.xml.in.in ├── input-tablet-missing-symbolic.svg └── org.freedesktop.Tuhi.svg ├── tuhi-server.py ├── tuhi-gui.in ├── tools ├── tuhi-gui-flatpak.py ├── exporter.py ├── tuhi-kete-sandboxed.py ├── parse_log.py ├── raw-log-converter.py └── tuhi-live.py ├── .github └── workflows │ └── main.yml ├── tuhi.in ├── org.freedesktop.Tuhi.json ├── README.md └── meson.build /tuhi/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext(meson.project_name(), preset: 'glib') 2 | -------------------------------------------------------------------------------- /example.ini: -------------------------------------------------------------------------------- 1 | [Device] 2 | Address=E2:43:03:67:0E:01 3 | UUID=dead00beef00 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .flatpak* 2 | flatpak* 3 | tuhi.egg-info 4 | __pycache__ 5 | *.swp 6 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | # Language list must be in alphabetical order 2 | it 3 | pl 4 | tr 5 | -------------------------------------------------------------------------------- /meson_install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if [ -z $DESTDIR ]; then 4 | PREFIX=${MESON_INSTALL_PREFIX:-/usr} 5 | 6 | # Update icon cache 7 | gtk-update-icon-cache -f -t $PREFIX/share/icons/hicolor 8 | 9 | # Install new schemas 10 | #glib-compile-schemas $PREFIX/share/glib-2.0/schemas/ 11 | fi 12 | -------------------------------------------------------------------------------- /po/POTFILES: -------------------------------------------------------------------------------- 1 | data/org.freedesktop.Tuhi.appdata.xml.in.in 2 | data/org.freedesktop.Tuhi.desktop.in 3 | 4 | data/ui/AboutDialog.ui.in 5 | data/ui/AppMenu.ui 6 | data/ui/Drawing.ui 7 | data/ui/DrawingPerspective.ui 8 | data/ui/ErrorPerspective.ui 9 | data/ui/MainWindow.ui 10 | data/ui/SetupPerspective.ui 11 | 12 | tuhi/gui/application.py 13 | tuhi/gui/config.py 14 | tuhi/gui/drawing.py 15 | tuhi/gui/drawingperspective.py 16 | tuhi/gui/window.py 17 | -------------------------------------------------------------------------------- /data/org.freedesktop.Tuhi.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Tuhi 3 | Comment=Utility to download drawings from the Wacom Ink range of devices 4 | Exec=tuhi 5 | # Translators: Do NOT translate or transliterate this text (this is an icon file name)! 6 | Icon=org.freedesktop.Tuhi 7 | Type=Application 8 | StartupNotify=true 9 | Categories=GTK;GNOME;Utility; 10 | # Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! 11 | Keywords=tablet;wacom;ink; 12 | -------------------------------------------------------------------------------- /tuhi/gui/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | -------------------------------------------------------------------------------- /tuhi-server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | import tuhi.base 15 | import sys 16 | 17 | if __name__ == "__main__": 18 | tuhi.base.main(sys.argv + ['--verbose']) 19 | -------------------------------------------------------------------------------- /data/org.freedesktop.Tuhi-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tuhi-gui.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import gi 4 | import sys 5 | import os 6 | from pathlib import Path 7 | 8 | try: 9 | # 3.30 is the first one with Gtk.Template 10 | gi.check_version('3.30') # NOQA 11 | except ValueError as e: 12 | print(e, file=sys.stderr) 13 | sys.exit(1) 14 | 15 | gi.require_version('Gio', '2.0') # NOQA 16 | from gi.repository import Gio 17 | 18 | 19 | @devel@ # NOQA 20 | resource = Gio.resource_load(os.fspath(Path('@pkgdatadir@', 'tuhi.gresource'))) 21 | Gio.Resource._register(resource) 22 | 23 | 24 | if __name__ == "__main__": 25 | import gettext 26 | import locale 27 | 28 | locale.bindtextdomain('tuhi', '@localedir@') 29 | gettext.bindtextdomain('tuhi', '@localedir@') 30 | 31 | from tuhi.gui.application import main 32 | main(sys.argv) 33 | -------------------------------------------------------------------------------- /data/tuhi.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | AboutDialog.ui 5 | ui/Drawing.ui 6 | ui/DrawingPerspective.ui 7 | ui/Flowbox.ui 8 | ui/MainWindow.ui 9 | ui/SetupPerspective.ui 10 | ui/ErrorPerspective.ui 11 | input-tablet-missing-symbolic.svg 12 | ui/AppMenu.ui 13 | 14 | 15 | -------------------------------------------------------------------------------- /data/ui/AppMenu.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | Portrait 6 | win.orientation 7 | portrait 8 | 9 | 10 | Landscape 11 | win.orientation 12 | landscape 13 | 14 |
15 |
16 | 17 | Help 18 | app.help 19 | 20 | 21 | About 22 | app.about 23 | 24 |
25 |
26 |
27 | -------------------------------------------------------------------------------- /tools/tuhi-gui-flatpak.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | import subprocess 15 | from multiprocessing import Process 16 | 17 | 18 | def start_tuhi(): 19 | subprocess.run('tuhi') 20 | 21 | 22 | def start_tuhigui(): 23 | subprocess.run('tuhi-gui') 24 | 25 | 26 | if __name__ == '__main__': 27 | tuhi = Process(target=start_tuhi) 28 | tuhi.daemon = True 29 | tuhi.start() 30 | tuhigui = Process(target=start_tuhigui) 31 | tuhigui.start() 32 | tuhigui.join() 33 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: [ push, pull_request ] 2 | 3 | env: 4 | CFLAGS: "-Werror -Wall -Wextra -Wno-error=sign-compare -Wno-error=unused-parameter -Wno-error=missing-field-initializers" 5 | UBUNTU_PACKAGES: meson gettext python3-dev python-gi-dev flake8 desktop-file-utils libappstream-glib-dev appstream-util python3-pytest python3-xdg python3-yaml python3-svgwrite python3-cairo 6 | 7 | jobs: 8 | meson_test: 9 | runs-on: ubuntu-24.04 10 | steps: 11 | - name: Install dependencies 12 | run: | 13 | sudo apt-get update -yq 14 | sudo apt-get install -yq --no-install-suggests --no-install-recommends $UBUNTU_PACKAGES 15 | - uses: actions/checkout@v4 16 | - name: meson 17 | run: meson builddir 18 | - name: ninja 19 | run: ninja -C builddir test 20 | - name: capture build logs 21 | uses: actions/upload-artifact@v4 22 | if: ${{ always() }} # even if we fail 23 | with: 24 | name: meson logs 25 | path: | 26 | builddir/meson-logs 27 | -------------------------------------------------------------------------------- /tuhi/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | 15 | def list2hex(lst, groupsize=8): 16 | '''Converts a list of integers to a two-letter hex string in the form 17 | "1a 2b c3"''' 18 | 19 | slices = [] 20 | for idx in range(0, len(lst), groupsize): 21 | s = ' '.join([f'{x:02x}' for x in lst[idx:idx + groupsize]]) 22 | slices.append(s) 23 | 24 | return ' '.join(slices) 25 | 26 | 27 | def flatten(items): 28 | '''flatten an array of mixed int and arrays into a simple array of int''' 29 | for item in items: 30 | if isinstance(item, int): 31 | yield item 32 | else: 33 | yield from flatten(item) 34 | -------------------------------------------------------------------------------- /tuhi/gui/README.md: -------------------------------------------------------------------------------- 1 | TuhiGui 2 | ======= 3 | 4 | Tuhi is a GUI to the Tuhi DBus daemon that connects to and fetches the data 5 | from the Wacom ink range (Spark, Slate, Folio, Intuos Paper, ...). The data 6 | is converted to SVG and users can save it on disk. 7 | 8 | For more info about Tuhi see: https://github.com/tuhiproject/tuhi 9 | 10 | 11 | Building TuhiGUI 12 | ---------------- 13 | 14 | ``` 15 | $> git clone http://github.com/tuhiproject/tuhigui 16 | $> cd tuhigui 17 | $> meson builddir 18 | $> ninja -C builddir 19 | $> ./builddir/tuhigui.devel 20 | ``` 21 | 22 | TuhiGui requires Python v3.6 or above. 23 | 24 | Install TuhiGUI 25 | --------------- 26 | 27 | ``` 28 | $> git clone http://github.com/tuhiproject/tuhigui 29 | $> cd tuhigui 30 | $> meson builddir 31 | $> ninja -C builddir install 32 | $> tuhigui 33 | ``` 34 | 35 | TuhiGui requires Python v3.6 or above. 36 | 37 | Flatpak 38 | ------- 39 | 40 | ``` 41 | $> git clone http://github.com/tuhiproject/tuhigui 42 | $> cd tuhigui 43 | $> flatpak-builder flatpak_builddir org.freedesktop.TuhiGui.json --install --user --force-clean 44 | $> flatpak run org.freedesktop.TuhiGui 45 | ``` 46 | 47 | License 48 | ------- 49 | 50 | TuhiGui is licensed under the GPLv2 or later. 51 | -------------------------------------------------------------------------------- /data/ui/AboutDialog.ui.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | False 7 | True 8 | normal 9 | Tuhi 10 | @version@ 11 | Copyright © 2020 Tuhi Developers 12 | @url@ 13 | Visit Tuhi’s website 14 | org.freedesktop.Tuhi 15 | gpl-2-0 16 | 17 | 18 | False 19 | 20 | 21 | False 22 | 23 | 24 | False 25 | False 26 | 0 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /po/README.md: -------------------------------------------------------------------------------- 1 | i18n 2 | ==== 3 | 4 | This directory contains the translations of Tuhi 5 | 6 | For errors in translations, please [file an 7 | issue](https://github.com/tuhiproject/tuhi/issues/new). 8 | 9 | New or updated translations are always welcome. To start a new translation, run: 10 | 11 | $ meson translation-build 12 | $ ninja -C translation-build tuhi-pot 13 | # Now you can optionally remove the build directory 14 | $ rm -rf translation-build 15 | $ cp po/tuhi.pot po/$lang.po 16 | 17 | where `$lang` is the language code of your target language, e.g. `nl` for Dutch 18 | or `en_GB` for British English. Edit the 19 | [LINGUAS](https://github.com/tuhiproject/tuhi/blob/master/tuhigui/po/LINGUAS) file and 20 | add your language code, keeping the list sorted alphabetically. Finally, open 21 | the `.po` file you just created and translate all the strings. Don't forget to 22 | fill in the information in the header! 23 | 24 | To update an existing translation, run: 25 | 26 | $ meson translation-build 27 | $ ninja -C translation-build tuhi-update-po 28 | # Now you can optionally remove the build directory 29 | $ rm -rf translation-build 30 | 31 | and update the `po/$lang.po` file of your target language. 32 | 33 | When you are done translating, file a pull request on 34 | [GitHub](https://github.com/tuhiproject/tuhi) or, if you don't know how to, [open 35 | an issue](https://github.com/tuhiproject/tuhi/issues/new) and attach the `.po` 36 | file there. 37 | 38 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | gnome = import('gnome') 2 | 3 | metainfodir = join_paths(datadir, 'metainfo') 4 | 5 | conf = configuration_data() 6 | conf.set('version', meson.project_version()) 7 | conf.set('url', 'https://github.com/tuhiproject/tuhi') 8 | conf.set('version_date', version_date) 9 | 10 | about_dialog = configure_file(input: 'ui/AboutDialog.ui.in', 11 | output: 'AboutDialog.ui', 12 | configuration: conf) 13 | 14 | install_data('org.freedesktop.Tuhi.svg', install_dir: icondir_scalable) 15 | install_data('org.freedesktop.Tuhi-symbolic.svg', install_dir: icondir_symbolic) 16 | 17 | i18n.merge_file(input: 'org.freedesktop.Tuhi.desktop.in', 18 | output: 'org.freedesktop.Tuhi.desktop', 19 | type: 'desktop', 20 | po_dir: podir, 21 | install: true, 22 | install_dir: desktopdir) 23 | 24 | appdata = configure_file(input: 'org.freedesktop.Tuhi.appdata.xml.in.in', 25 | output: 'org.freedesktop.Tuhi.appdata.xml.in', 26 | configuration: conf) 27 | 28 | i18n.merge_file(input: appdata, 29 | output: 'org.freedesktop.Tuhi.appdata.xml', 30 | type: 'xml', 31 | po_dir: podir, 32 | install: true, 33 | install_dir: metainfodir) 34 | 35 | 36 | gnome.compile_resources('tuhi', 'tuhi.gresource.xml', 37 | source_dir: '.', 38 | dependencies: [about_dialog], 39 | gresource_bundle: true, 40 | install: true, 41 | install_dir: pkgdatadir) 42 | -------------------------------------------------------------------------------- /tuhi.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | import sys 15 | import subprocess 16 | from pathlib import Path 17 | import argparse 18 | 19 | tuhi_server = Path('@libexecdir@', 'tuhi-server') 20 | tuhi_gui = Path('@libexecdir@', 'tuhi-gui') 21 | 22 | 23 | @devel@ # NOQA 24 | 25 | if __name__ == '__main__': 26 | if sys.version_info < (3, 6): 27 | sys.exit('Python 3.6 or later required') 28 | 29 | parser = argparse.ArgumentParser(description='Tuhi') 30 | parser.add_argument('--flatpak-compatibility-mode', 31 | help='Use the flatpak xdg directories', 32 | action='store_true', 33 | default=False) 34 | ns, remainder = parser.parse_known_args() 35 | if ns.flatpak_compatibility_mode: 36 | import os 37 | 38 | basedir = Path.home() / '.var' / 'app' / 'org.freedesktop.Tuhi' 39 | print(f'Using flatpak xdg dirs in {basedir}') 40 | os.environ['XDG_DATA_HOME'] = os.fspath(basedir / 'data') 41 | os.environ['XDG_CONFIG_HOME'] = os.fspath(basedir / 'config') 42 | os.environ['XDG_CACHE_HOME'] = os.fspath(basedir / 'cache') 43 | 44 | tuhi = subprocess.Popen([tuhi_server] + remainder) 45 | try: 46 | subprocess.run([tuhi_gui] + remainder) 47 | except KeyboardInterrupt: 48 | pass 49 | tuhi.terminate() 50 | -------------------------------------------------------------------------------- /tools/exporter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | from pathlib import Path 15 | import argparse 16 | import json 17 | import os 18 | import sys 19 | 20 | # This tool isn't installed, so we can assume that the tuhi module is always 21 | # in the parent directory 22 | sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)) + '/..') # noqa 23 | from tuhi.export import JsonSvg, JsonPng # noqa: E402 24 | 25 | parser = argparse.ArgumentParser(description='Converter tool from Tuhi JSON files to SVG or PNG.') 26 | parser.add_argument('filename', help='The JSON file to export ($HOME/.local/share/tuhi/*.json)') 27 | parser.add_argument('--format', 28 | help='The format to generate. Default: svg', 29 | default='svg', 30 | choices=['svg', 'png']) 31 | parser.add_argument('--output', 32 | type=str, 33 | help='The output file name. Default: "$PWD/inputfile.suffix"', 34 | default=None) 35 | parser.add_argument('--orientation', 36 | help='The orientation of the image', 37 | default='landscape', 38 | choices=['landscape', 'portrait', 'reverse-landscape', 'reverse-portrait']) 39 | 40 | ns = parser.parse_args() 41 | 42 | if ns.output is None: 43 | ns.output = f"{Path(ns.filename).stem}.{ns.format}" 44 | 45 | js = json.load(open(ns.filename)) 46 | if ns.format == 'svg': 47 | JsonSvg(js, ns.orientation, ns.output) 48 | elif ns.format == 'png': 49 | JsonPng(js, ns.orientation, ns.output) 50 | -------------------------------------------------------------------------------- /data/org.freedesktop.Tuhi.appdata.xml.in.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | org.freedesktop.Tuhi 4 | FSFAP 5 | GPL-2.0+ 6 | 7 | Tuhi 8 | Utility to download drawings from the Wacom Ink range of devices 9 | 10 |

11 | Tuhi is a graphical user interface to download drawings stored on 12 | tablet devices from the Wacom Ink range, e.g. Intuos Pro Paper or 13 | Bamboo Slate. 14 |

15 |

16 | Tuhi requires Tuhi, the daemon to actually communicate with the 17 | devices. ThiGui is merely a front end to Tuhi, Tuhi must be 18 | installed and running when Tuhi is launched. 19 |

20 |
21 | 22 | 23 | AppMenu 24 | HiDpiIcon 25 | ModernToolkit 26 | 27 | 28 | org.freedesktop.Tuhi.desktop 29 | 30 | 31 | 32 | Tuhi's main window 33 | https://raw.githubusercontent.com/tuhiproject/tuhi/screenshots/main-window.png 34 | 35 | 36 | Tuhi's main window (zoomed) 37 | https://raw.githubusercontent.com/tuhiproject/tuhi/screenshots/main-window-zoomed.png 38 | 39 | 40 | 41 | https://github.com/tuhiproject/tuhi/ 42 | https://github.com/tuhiproject/tuhi/issues 43 | https://github.com/tuhiproject/tuhi/wiki 44 | https://github.com/tuhiproject/tuhi/ 45 | The Tuhi Project 46 | 47 | tuhi 48 | 49 | 50 | tuhi 51 | 52 | 53 | 54 | 55 | 56 |
57 | -------------------------------------------------------------------------------- /data/ui/Flowbox.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 56 | 57 | -------------------------------------------------------------------------------- /tools/tuhi-kete-sandboxed.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | import sys 15 | import multiprocessing 16 | 17 | 18 | def maybe_start_tuhi(queue): 19 | should_start = queue.get() 20 | 21 | if not should_start: 22 | return 23 | 24 | import tuhi.base 25 | tuhi.base.main(['tuhi']) 26 | 27 | 28 | def main(args=sys.argv): 29 | 30 | queue = multiprocessing.Queue() 31 | 32 | tuhi_process = multiprocessing.Process(target=maybe_start_tuhi, args=(queue,)) 33 | tuhi_process.daemon = True 34 | tuhi_process.start() 35 | 36 | # import after spawning the process, or the 2 processes will fight for GLib 37 | import kete 38 | from gi.repository import Gio, GLib 39 | 40 | # connect to the session 41 | try: 42 | connection = Gio.bus_get_sync(Gio.BusType.SESSION, None) 43 | except GLib.Error as e: 44 | if (e.domain == 'g-io-error-quark' and 45 | e.code == Gio.IOErrorEnum.DBUS_ERROR): 46 | raise kete.DBusError(e.message) 47 | else: 48 | raise e 49 | 50 | # attempt to connect to tuhi 51 | try: 52 | proxy = Gio.DBusProxy.new_sync(connection, 53 | Gio.DBusProxyFlags.NONE, None, 54 | kete.TUHI_DBUS_NAME, 55 | kete.ROOT_PATH, 56 | kete.ORG_FREEDESKTOP_TUHI1_MANAGER, 57 | None) 58 | except GLib.Error as e: 59 | if (e.domain == 'g-io-error-quark' and 60 | e.code == Gio.IOErrorEnum.DBUS_ERROR): 61 | raise kete.DBusError(e.message) 62 | else: 63 | raise e 64 | 65 | started = proxy.get_name_owner() is not None 66 | 67 | if not started: 68 | print(f'No-one is handling {kete.TUHI_DBUS_NAME}, attempting to start a daemon') 69 | 70 | queue.put(not started) 71 | 72 | kete.main(args) 73 | 74 | 75 | if __name__ == '__main__': 76 | main(sys.argv) 77 | -------------------------------------------------------------------------------- /org.freedesktop.Tuhi.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id": "org.freedesktop.Tuhi", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "46", 5 | "sdk": "org.gnome.Sdk", 6 | "command": "tuhi", 7 | "finish-args": [ 8 | "--share=ipc", 9 | "--socket=x11", 10 | "--socket=wayland", 11 | "--talk-name=org.freedesktop.tuhi1", 12 | "--own-name=org.freedesktop.tuhi1", 13 | "--system-talk-name=org.bluez", 14 | "--filesystem=home" 15 | ], 16 | "modules": [ 17 | { 18 | "name": "pyxdg", 19 | "buildsystem": "simple", 20 | "sources": [ 21 | { 22 | "type": "git", 23 | "url": "https://gitlab.freedesktop.org/xdg/pyxdg.git", 24 | "tag": "rel-0.27", 25 | "commit": "f097a66923a65e93640c48da83e6e9cfbddd86ba" 26 | } 27 | ], 28 | "build-commands": [ 29 | "pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} ." 30 | ] 31 | }, 32 | { 33 | "name": "python-pyparsing", 34 | "buildsystem": "simple", 35 | "sources": [ 36 | { 37 | "type": "archive", 38 | "url": "https://github.com/pyparsing/pyparsing/releases/download/pyparsing_2.4.7/pyparsing-2.4.7.tar.gz", 39 | "sha512": "0b9f8f18907f65cb3af1b48ed57989e183f28d71646f2b2f820e772476f596ca15ee1a689f3042f18458206457f4683d10daa6e73dfd3ae82d5e4405882f9dd2" 40 | } 41 | ], 42 | "build-commands": [ 43 | "pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} ." 44 | ] 45 | }, 46 | { 47 | "name": "python-svgwrite", 48 | "buildsystem": "simple", 49 | "sources": [ 50 | { 51 | "type": "git", 52 | "url": "https://github.com/mozman/svgwrite.git", 53 | "tag": "v1.4.2", 54 | "commit": "e2617741ab018956e638e18aa21827405bd8edd1" 55 | } 56 | ], 57 | "build-commands": [ 58 | "pip3 install --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} ." 59 | ] 60 | }, 61 | { 62 | "name": "tuhi", 63 | "buildsystem": "meson", 64 | "sources": [ 65 | { 66 | "type": "git", 67 | "url": "." 68 | } 69 | ] 70 | } 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /data/input-tablet-missing-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 18 | 19 | 21 | image/svg+xml 22 | 24 | 25 | 26 | 27 | 28 | 30 | 50 | 58 | 68 | 74 | 79 | 80 | 86 | 87 | -------------------------------------------------------------------------------- /tuhi/gui/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | 15 | from gi.repository import GObject 16 | 17 | import configparser 18 | import logging 19 | import json 20 | from pathlib import Path 21 | 22 | logger = logging.getLogger('tuhi.gui.config') 23 | 24 | 25 | class Config(GObject.Object): 26 | _instance = None 27 | _base_path = None 28 | 29 | def __new__(cls): 30 | if cls._instance is None: 31 | cls._instance = super(Config, cls).__new__(cls) 32 | 33 | self = cls._instance 34 | self.__init__() # for GObject to initialize 35 | self.path = Path(self._base_path, 'tuhigui.ini') 36 | self.base_path = self._base_path 37 | self.config = configparser.ConfigParser() 38 | # Don't lowercase options 39 | self.config.optionxform = str 40 | self._drawings = [] 41 | self._load() 42 | self._load_cached_drawings() 43 | return cls._instance 44 | 45 | def _load(self): 46 | if not self.path.exists(): 47 | return 48 | 49 | logger.debug('configuration found') 50 | self.config.read(self.path) 51 | 52 | def _load_cached_drawings(self): 53 | if not self.base_path.exists(): 54 | return 55 | 56 | for filename in self.base_path.glob('*.json'): 57 | with open(filename) as fd: 58 | self._drawings.append(json.load(fd)) 59 | self.notify('drawings') 60 | 61 | def _write(self): 62 | self.path.resolve().parent.mkdir(parents=True, exist_ok=True) 63 | with open(self.path, 'w') as fd: 64 | self.config.write(fd) 65 | 66 | def _add_key(self, section, key, value): 67 | if section not in self.config: 68 | self.config[section] = {} 69 | self.config[section][key] = value 70 | self._write() 71 | 72 | @GObject.Property 73 | def orientation(self): 74 | try: 75 | return self.config['Device']['Orientation'] 76 | except KeyError: 77 | return 'landscape' 78 | 79 | @orientation.setter 80 | def orientation(self, orientation): 81 | assert orientation in ['landscape', 'portrait'] 82 | self._add_key('Device', 'Orientation', orientation) 83 | 84 | @GObject.Property 85 | def drawings(self): 86 | return self._drawings 87 | 88 | def add_drawing(self, timestamp, json_string): 89 | '''Add a drawing JSON with the given timestamp to the backend 90 | storage. This will update self.drawings.''' 91 | self.base_path.mkdir(parents=True, exist_ok=True) 92 | 93 | path = Path(self.base_path, f'{timestamp}.json') 94 | if path.exists(): 95 | return 96 | 97 | # Tuhi may still cache files we've 'deleted' locally. These need to 98 | # be ignored because they're still technically deleted. 99 | deleted = Path(self.base_path, f'{timestamp}.json.deleted') 100 | if deleted.exists(): 101 | return 102 | 103 | with open(path, 'w') as fd: 104 | fd.write(json_string) 105 | 106 | self._drawings.append(json.loads(json_string)) 107 | self.notify('drawings') 108 | 109 | def delete_drawing(self, timestamp): 110 | # We don't delete json files immediately, we just rename them 111 | # so we can resurrect them in the future if need be. 112 | path = Path(self.base_path, f'{timestamp}.json') 113 | target = Path(self.base_path, f'{timestamp}.json.deleted') 114 | path.rename(target) 115 | 116 | self._drawings = [d for d in self._drawings if d['timestamp'] != timestamp] 117 | self.notify('drawings') 118 | 119 | def undelete_drawing(self, timestamp): 120 | path = Path(self.base_path, f'{timestamp}.json') 121 | target = Path(self.base_path, f'{timestamp}.json.deleted') 122 | target.rename(path) 123 | 124 | with open(path) as fd: 125 | self._drawings.append(json.load(fd)) 126 | self.notify('drawings') 127 | 128 | @classmethod 129 | def set_base_path(cls, path): 130 | if cls._instance is not None: 131 | logger.error('Trying to set config base path but we already have the singleton object') 132 | return 133 | 134 | cls._base_path = Path(path) 135 | -------------------------------------------------------------------------------- /data/ui/ErrorPerspective.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 117 | 118 | -------------------------------------------------------------------------------- /tuhi/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | from gi.repository import GObject 15 | 16 | import configparser 17 | import re 18 | import logging 19 | from pathlib import Path 20 | from .drawing import Drawing 21 | from .protocol import ProtocolVersion 22 | 23 | logger = logging.getLogger('tuhi.config') 24 | 25 | 26 | def is_btaddr(addr): 27 | return re.match('^([0-9A-F]{2}[:-]){5}([0-9A-F]{2})$', addr) is not None 28 | 29 | 30 | class TuhiConfig(GObject.Object): 31 | _instance = None 32 | _base_path = None 33 | 34 | def __new__(cls): 35 | if cls._instance is None: 36 | cls._instance = super(TuhiConfig, cls).__new__(cls) 37 | self = cls._instance 38 | self.__init__() # for GObject to initialize 39 | logger.debug(f'Using config directory: {self._base_path}') 40 | Path(self._base_path).mkdir(parents=True, exist_ok=True) 41 | 42 | self._devices = {} 43 | self._scan_config_dir() 44 | self.peek_at_drawing = False 45 | return cls._instance 46 | 47 | @property 48 | def log_dir(self): 49 | ''' 50 | The pathlib.Path to the directory to store log files in. 51 | ''' 52 | return Path(self._base_path) 53 | 54 | @GObject.Property 55 | def devices(self): 56 | ''' 57 | Returns a dictionary with the bluetooth address as key 58 | ''' 59 | return self._devices 60 | 61 | def _scan_config_dir(self): 62 | dirs = [d for d in Path(self._base_path).iterdir() if d.is_dir() and is_btaddr(d.name)] 63 | for directory in dirs: 64 | settings = Path(directory, 'settings.ini') 65 | if not settings.is_file(): 66 | continue 67 | 68 | logger.debug(f'{directory}: configuration found') 69 | config = configparser.ConfigParser() 70 | config.read(settings) 71 | 72 | btaddr = directory.name 73 | assert config['Device']['Address'] == btaddr 74 | if 'Protocol' not in config['Device']: 75 | config['Device']['Protocol'] = ProtocolVersion.ANY.name.lower() 76 | self._devices[btaddr] = config['Device'] 77 | 78 | def new_device(self, address, uuid, protocol): 79 | assert is_btaddr(address) 80 | assert len(uuid) == 12 81 | assert protocol != ProtocolVersion.ANY 82 | 83 | logger.debug(f'{address}: adding new config, UUID {uuid}') 84 | path = Path(self._base_path, address) 85 | path.mkdir(exist_ok=True) 86 | 87 | # The ConfigParser default is to write out options as lowercase, but 88 | # the ini standard is Capitalized. But it's convenient to have 89 | # write-out nice but read-in flexible. So have two different config 90 | # parsers for writing and then for handling the reads later 91 | path = Path(path, 'settings.ini') 92 | config = configparser.ConfigParser() 93 | config.optionxform = str 94 | config.read(path) 95 | 96 | config['Device'] = { 97 | 'Address': address, 98 | 'UUID': uuid, 99 | 'Protocol': protocol.name.lower(), 100 | } 101 | 102 | with open(path, 'w') as configfile: 103 | config.write(configfile) 104 | 105 | config = configparser.ConfigParser() 106 | config.read(path) 107 | self._devices[address] = config['Device'] 108 | 109 | def store_drawing(self, address, drawing): 110 | assert is_btaddr(address) 111 | assert drawing is not None 112 | 113 | if address not in self.devices: 114 | logger.error(f'{address}: cannot store drawings for unknown device') 115 | return 116 | 117 | logger.debug(f'{address}: adding new drawing, timestamp {drawing.timestamp}') 118 | path = Path(self._base_path, address, f'{drawing.timestamp}.json') 119 | 120 | with open(path, 'w') as f: 121 | f.write(drawing.to_json()) 122 | 123 | def load_drawings(self, address): 124 | assert is_btaddr(address) 125 | 126 | if address not in self.devices: 127 | return [] 128 | 129 | configdir = Path(self._base_path, address) 130 | return [Drawing.from_json(f) for f in configdir.glob('*.json')] 131 | 132 | @classmethod 133 | def set_base_path(cls, path): 134 | if cls._instance is not None: 135 | logger.error('Trying to set config base path but we already have the singleton object') 136 | return 137 | 138 | cls._base_path = Path(path) 139 | -------------------------------------------------------------------------------- /tuhi/drawing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | from gi.repository import GObject 15 | import json 16 | import logging 17 | 18 | logger = logging.getLogger('tuhi.drawing') 19 | 20 | 21 | class Point(GObject.Object): 22 | def __init__(self, stroke): 23 | GObject.Object.__init__(self) 24 | self.stroke = stroke 25 | self.position = None 26 | self.pressure = None 27 | 28 | def to_dict(self): 29 | d = {} 30 | for key in ['position', 'pressure']: 31 | val = getattr(self, key, None) 32 | if val is not None: 33 | d[key] = val 34 | return d 35 | 36 | 37 | class Stroke(GObject.Object): 38 | def __init__(self, drawing): 39 | GObject.Object.__init__(self) 40 | self.drawing = drawing 41 | self.points = [] 42 | self._position = (0, 0) 43 | self._pressure = 0 44 | self._is_sealed = False 45 | 46 | @GObject.Property 47 | def sealed(self): 48 | return self._is_sealed 49 | 50 | def seal(self): 51 | self._is_sealed = True 52 | 53 | def new_rel(self, position=None, pressure=None): 54 | assert not self._is_sealed 55 | 56 | p = Point(self) 57 | if position is not None: 58 | x, y = self._position 59 | self._position = (x + position[0], y + position[1]) 60 | p.position = self._position 61 | if pressure is not None: 62 | self._pressure += pressure 63 | p.pressure = self._pressure 64 | 65 | self.points.append(p) 66 | 67 | def new_abs(self, position=None, pressure=None): 68 | assert not self._is_sealed 69 | 70 | p = Point(self) 71 | if position is not None: 72 | self._position = position 73 | p.position = position 74 | if pressure is not None: 75 | self._pressure = pressure 76 | p.pressure = pressure 77 | 78 | self.points.append(p) 79 | 80 | def to_dict(self): 81 | d = {} 82 | d['points'] = [p.to_dict() for p in self.points] 83 | return d 84 | 85 | 86 | class Drawing(GObject.Object): 87 | ''' 88 | Abstracts a drawing. The drawing is composed Strokes, each of which has 89 | Points. 90 | ''' 91 | JSON_FILE_FORMAT_VERSION = 1 92 | 93 | def __init__(self, name, dimensions, timestamp): 94 | GObject.Object.__init__(self) 95 | self.name = name 96 | self.dimensions = dimensions 97 | self.timestamp = timestamp # unix seconds 98 | self.strokes = [] 99 | self._current_stroke = -1 100 | self.session_id = 'unset' 101 | 102 | def seal(self): 103 | # Drop empty strokes 104 | for s in self.strokes: 105 | s.seal() 106 | self.strokes = [s for s in self.strokes if s.points] 107 | 108 | # The way we're building drawings, we don't need to change the current 109 | # stroke at runtime, so this is read-ony 110 | @GObject.Property 111 | def current_stroke(self): 112 | if self._current_stroke < 0: 113 | return None 114 | 115 | s = self.strokes[self._current_stroke] 116 | return s if not s.sealed else None 117 | 118 | def new_stroke(self): 119 | ''' 120 | Create a new stroke and make it the current stroke 121 | ''' 122 | if self.current_stroke is not None: 123 | self.current_stroke.seal() 124 | 125 | s = Stroke(self) 126 | self.strokes.append(s) 127 | self._current_stroke += 1 128 | return s 129 | 130 | def to_json(self): 131 | json_data = { 132 | 'version': self.JSON_FILE_FORMAT_VERSION, 133 | 'devicename': self.name, 134 | 'sessionid': self.session_id, 135 | 'dimensions': list(self.dimensions), 136 | 'timestamp': self.timestamp, 137 | 'strokes': [s.to_dict() for s in self.strokes] 138 | } 139 | return json.dumps(json_data, indent=2) 140 | 141 | @classmethod 142 | def from_json(cls, path): 143 | d = None 144 | with open(path, 'r') as fp: 145 | json_data = json.load(fp) 146 | 147 | try: 148 | if json_data['version'] != cls.JSON_FILE_FORMAT_VERSION: 149 | logger.error(f'{path}: Invalid file format version') 150 | return d 151 | 152 | name = json_data['devicename'] 153 | dimensions = tuple(json_data['dimensions']) 154 | timestamp = json_data['timestamp'] 155 | d = Drawing(name, dimensions, timestamp) 156 | 157 | for s in json_data['strokes']: 158 | stroke = d.new_stroke() 159 | for p in s['points']: 160 | position = p.get('position', None) 161 | pressure = p.get('pressure', None) 162 | stroke.new_abs(position, pressure) 163 | except KeyError: 164 | logger.error(f'{path}: failed to parse json file') 165 | 166 | return d 167 | 168 | def __repr__(self): 169 | return f'Drawing from {self.name} at {self.timestamp}, {len(self.strokes)} strokes' 170 | -------------------------------------------------------------------------------- /tuhi/gui/application.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | from .window import MainWindow 15 | from .config import Config 16 | from pathlib import Path 17 | 18 | import logging 19 | import sys 20 | import xdg.BaseDirectory 21 | import gi 22 | gi.require_version("Gio", "2.0") 23 | gi.require_version("Gtk", "3.0") 24 | from gi.repository import Gio, GLib, Gtk, Gdk # NOQA 25 | 26 | logging.basicConfig(format='%(asctime)s %(levelname)s: %(name)s: %(message)s', 27 | level=logging.INFO, 28 | datefmt='%H:%M:%S') 29 | logger = logging.getLogger('tuhi.gui') 30 | 31 | DEFAULT_CONFIG_PATH = Path(xdg.BaseDirectory.xdg_data_home, 'tuhi') 32 | 33 | 34 | class Application(Gtk.Application): 35 | def __init__(self): 36 | super().__init__(application_id='org.freedesktop.Tuhi', 37 | flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) 38 | GLib.set_application_name('Tuhi') 39 | self.add_main_option('config-dir', 0, 40 | GLib.OptionFlags.NONE, 41 | GLib.OptionArg.STRING, 42 | 'path to configuration directory', 43 | '/path/to/config-dir') 44 | self.add_main_option('verbose', 0, 45 | GLib.OptionFlags.NONE, 46 | GLib.OptionArg.NONE, 47 | 'enable verbose output') 48 | # unused, just here to have option compatibility with the tuhi 49 | # server but we could add some GUI feedback here 50 | self.add_main_option('peek', 0, 51 | GLib.OptionFlags.NONE, 52 | GLib.OptionArg.NONE, 53 | 'download first drawing only but do not remove it from the device') 54 | 55 | self.set_accels_for_action('app.quit', ['Q']) 56 | 57 | self._tuhi = None 58 | 59 | def do_startup(self): 60 | Gtk.Application.do_startup(self) 61 | self._build_app_menu() 62 | 63 | def do_activate(self): 64 | window = MainWindow(application=self) 65 | window.present() 66 | 67 | def do_command_line(self, command_line): 68 | options = command_line.get_options_dict() 69 | # convert GVariantDict -> GVariant -> dict 70 | options = options.end().unpack() 71 | 72 | try: 73 | Config.set_base_path(options['config-dir']) 74 | except KeyError: 75 | Config.set_base_path(DEFAULT_CONFIG_PATH) 76 | 77 | if 'verbose' in options: 78 | logger.setLevel(logging.DEBUG) 79 | 80 | self.activate() 81 | return 0 82 | 83 | def _build_app_menu(self): 84 | actions = [('about', self._about), 85 | ('quit', self._quit), 86 | ('help', self._help)] 87 | for (name, callback) in actions: 88 | action = Gio.SimpleAction.new(name, None) 89 | action.connect('activate', callback) 90 | self.add_action(action) 91 | 92 | def _about(self, action, param): 93 | builder = Gtk.Builder().new_from_resource('/org/freedesktop/Tuhi/AboutDialog.ui') 94 | about = builder.get_object('about_dialog') 95 | about.set_transient_for(self.get_active_window()) 96 | about.connect('response', lambda about, param: about.destroy()) 97 | about.show() 98 | 99 | def _quit(self, action, param): 100 | windows = self.get_windows() 101 | for window in windows: 102 | window.destroy() 103 | 104 | def _help(self, action, param): 105 | import time 106 | Gtk.show_uri(None, 'https://github.com/tuhiproject/tuhi/wiki', time.time()) 107 | 108 | 109 | def install_excepthook(): 110 | old_hook = sys.excepthook 111 | 112 | def new_hook(etype, evalue, etb): 113 | old_hook(etype, evalue, etb) 114 | while Gtk.main_level(): 115 | Gtk.main_quit() 116 | sys.exit() 117 | sys.excepthook = new_hook 118 | 119 | 120 | def gtk_style(): 121 | css = b""" 122 | flowboxchild:selected { 123 | background-color: white; 124 | } 125 | .bg-white { 126 | background-color: white; 127 | } 128 | .bg-paper { 129 | border-radius: 5px; 130 | background-color: #ebe9e8; 131 | } 132 | .drawing { 133 | background-color: white; 134 | border-radius: 5px; 135 | } 136 | """ 137 | 138 | screen = Gdk.Screen.get_default() 139 | if screen is None: 140 | print('Error: Unable to connect to screen. Make sure DISPLAY or WAYLAND_DISPLAY are set', file=sys.stderr) 141 | sys.exit(1) 142 | style_provider = Gtk.CssProvider() 143 | style_provider.load_from_data(css) 144 | Gtk.StyleContext.add_provider_for_screen(screen, 145 | style_provider, 146 | Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) 147 | 148 | 149 | def main(argv): 150 | if sys.version_info < (3, 6): 151 | sys.exit('Python 3.6 or later required') 152 | 153 | import gettext 154 | import locale 155 | import signal 156 | 157 | install_excepthook() 158 | gtk_style() 159 | 160 | locale.textdomain('tuhi') 161 | gettext.textdomain('tuhi') 162 | signal.signal(signal.SIGINT, signal.SIG_DFL) 163 | exit_status = Application().run(argv) 164 | sys.exit(exit_status) 165 | -------------------------------------------------------------------------------- /tuhi/export.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | from gi.repository import GObject 15 | import svgwrite 16 | from svgwrite import mm 17 | import cairo 18 | 19 | 20 | class ImageExportBase(GObject.Object): 21 | 22 | def __init__(self, json, orientation, filename, *args, **kwargs): 23 | super().__init__(*args, **kwargs) 24 | self.json = json 25 | self.timestamp = json['timestamp'] 26 | self.filename = filename 27 | self.orientation = orientation.lower() 28 | self._convert() 29 | 30 | @property 31 | def output_dimensions(self): 32 | dimensions = self.json['dimensions'] 33 | if dimensions == [0, 0]: 34 | width, height = 100, 100 35 | else: 36 | # Original dimensions are too big for most Standards 37 | # so we scale them down 38 | width = dimensions[0] / self._output_scaling_factor 39 | height = dimensions[1] / self._output_scaling_factor 40 | 41 | if self.orientation in ['portrait', 'reverse-portrait']: 42 | return height, width 43 | else: 44 | return width, height 45 | 46 | @property 47 | def output_strokes(self): 48 | 49 | width, height = self.output_dimensions 50 | strokes = [] 51 | 52 | for s in self.json['strokes']: 53 | points_with_sk_width = [] 54 | 55 | for p in s['points']: 56 | 57 | x, y = p['position'] 58 | # Scaling coordinates 59 | x = x / self._output_scaling_factor 60 | y = y / self._output_scaling_factor 61 | 62 | if self.orientation == 'reverse-portrait': 63 | x, y = y, height - x 64 | elif self.orientation == 'portrait': 65 | x, y = width - y, x 66 | elif self.orientation == 'reverse-landscape': 67 | x, y = width - x, height - y 68 | 69 | # Pressure normalized range is [0, 0xffff] 70 | delta = (p['pressure'] - 0x8000) / 0x8000 71 | stroke_width = self._base_pen_width + self._pen_pressure_width_factor * delta 72 | points_with_sk_width.append((x, y, stroke_width)) 73 | 74 | strokes.append(points_with_sk_width) 75 | 76 | return strokes 77 | 78 | 79 | class JsonSvg(ImageExportBase): 80 | 81 | _output_scaling_factor = 1000 82 | _base_pen_width = 0.4 83 | _pen_pressure_width_factor = 0.2 84 | 85 | # Change this value down to reduce size, change it up to improve accuracy. measured in px 86 | _width_precision = 10 87 | 88 | def _convert(self): 89 | 90 | width, height = self.output_dimensions 91 | size = width * mm, height * mm 92 | 93 | # Make sure to set viewBox here so mm doesn't have to be specified in all later parts 94 | svg = svgwrite.Drawing(filename=self.filename, size=size, viewBox=(f'0 0 {width} {height}')) 95 | 96 | g = svgwrite.container.Group(id='layer0') 97 | for sk_num, stroke_points in enumerate(self.output_strokes): 98 | path = None 99 | stroke_width_p = None 100 | for i, (x, y, stroke_width) in enumerate(stroke_points): 101 | if not x or not y: 102 | continue 103 | 104 | # Reduce precision of the width 105 | stroke_width = int(stroke_width * self._width_precision) / self._width_precision 106 | 107 | # Create a new path per object and per unique width 108 | if stroke_width_p != stroke_width: 109 | if path: 110 | g.add(path) 111 | # Reduce width by mm to px at 96dpi (see SVG/CSS specification) 112 | width_px = stroke_width * 0.26458 113 | path = svg.path(id=f'sk_{sk_num}_{i}', style=f'fill:none;stroke:black;stroke-width:{width_px}') 114 | stroke_width_p = stroke_width 115 | path.push("M", f'{x:.2f}', f'{y:.2f}') 116 | 117 | else: 118 | # Continue writing segment line with next coords 119 | path.push("L", f'{x:.2f}', f'{y:.2f}') 120 | 121 | if path: 122 | g.add(path) 123 | 124 | svg.add(g) 125 | svg.save() 126 | 127 | 128 | class JsonPng(ImageExportBase): 129 | 130 | _output_scaling_factor = 100 131 | _base_pen_width = 3 132 | _pen_pressure_width_factor = 1 133 | 134 | def _convert(self): 135 | 136 | width, height = self.output_dimensions 137 | width, height = int(width), int(height) 138 | surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) 139 | ctx = cairo.Context(surface) 140 | 141 | # Paint a transparent background 142 | ctx.set_source_rgba(0, 0, 0, 0) 143 | ctx.paint() 144 | 145 | ctx.set_antialias(cairo.Antialias.DEFAULT) 146 | ctx.set_line_join(cairo.LINE_JOIN_ROUND) 147 | ctx.set_source_rgb(0, 0, 0) 148 | 149 | for sk_num, stroke_points in enumerate(self.output_strokes): 150 | for i, (x, y, stroke_width) in enumerate(stroke_points): 151 | ctx.set_line_width(stroke_width) 152 | 153 | if i == 0: 154 | ctx.move_to(x, y) 155 | else: 156 | ctx.line_to(x, y) 157 | 158 | ctx.stroke() 159 | 160 | surface.write_to_png(self.filename) 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![tuhi-logo](data/org.freedesktop.Tuhi.svg) 2 | 3 | Tuhi 4 | ===== 5 | 6 | Tuhi is a GTK application that connects to and fetches the data from the 7 | Wacom ink range (Spark, Slate, Folio, Intuos Paper, ...). Users can save the 8 | data as SVGs. 9 | 10 | Tuhi is the Māori word for "to draw". 11 | 12 | Supported Devices 13 | ----------------- 14 | 15 | Devices tested and known to be supported: 16 | 17 | * Bamboo Spark 18 | * Bamboo Slate 19 | * Intuos Pro Paper 20 | 21 | Building Tuhi 22 | ------------- 23 | 24 | To build and run Tuhi from the repository directly: 25 | 26 | ``` 27 | $> git clone http://github.com/tuhiproject/tuhi 28 | $> cd tuhi 29 | $> meson setup builddir 30 | $> ninja -C builddir 31 | $> ./builddir/tuhi.devel 32 | ``` 33 | 34 | Tuhi requires Python v3.6 or above. 35 | 36 | Installing Tuhi 37 | --------------- 38 | 39 | To install and run Tuhi: 40 | 41 | ``` 42 | $> git clone http://github.com/tuhiproject/tuhi 43 | $> cd tuhi 44 | $> meson setup builddir 45 | $> ninja -C builddir install 46 | ``` 47 | 48 | Run Tuhi with: 49 | 50 | ``` 51 | $> tuhi 52 | ``` 53 | 54 | Tuhi requires Python v3.6 or above. 55 | 56 | Flatpak 57 | ------- 58 | 59 | ``` 60 | $> git clone http://github.com/tuhiproject/tuhi 61 | $> cd tuhi 62 | $> flatpak-builder flatpak_builddir org.freedesktop.Tuhi.json --install --user --force-clean 63 | $> flatpak run org.freedesktop.Tuhi 64 | ``` 65 | 66 | Note that Flatpak's containers use different XDG directories. This affects 67 | Tuhi being able to remember devices and the data storage. Switching between 68 | the Flatpak and a normal installation requires re-registering the device and 69 | previously downloaded drawings may become inaccessible. 70 | 71 | License 72 | ------- 73 | 74 | Tuhi is licensed under the GPLv2 or later. 75 | 76 | Registering devices 77 | ------------------- 78 | 79 | For a device to work with Tuhi, it must be registered first. This is 80 | achieved by holiding the device button for 6 or more seconds until the blue 81 | LED starts blinking. Only in that mode can Tuhi detect it during 82 | `Searching` and register it. 83 | 84 | Registration sends a randomly generated UUID to the device. Subsequent 85 | connections must use that UUID as identifier for the tablet device to 86 | respond. Without knowing that UUID, other applications cannot connect. 87 | 88 | A device can only be registered with one application at a time. Thus, when a 89 | device is registered with Tuhi, other applications (e.g. Wacom Inkspace) 90 | cannot not connect to the device anymore. Likewise, when registered with 91 | another application, Tuhi cannot connect. 92 | 93 | To make the tablet connect again, simply re-register with the respective 94 | application or Tuhi, whichever desired. 95 | 96 | This is not registering the device with some cloud service, vendor, or 97 | other networked service. It is a communication between Tuhi and the firmware 98 | on the device only. It is merely a process of "your ID is now $foo" followed 99 | by "hi $foo, I want to connect". 100 | 101 | The word "register" was chosen because "pairing" is already in use by 102 | Bluetooth. 103 | 104 | Packages 105 | -------- 106 | 107 | Arch Linux: [tuhi-git](https://aur.archlinux.org/packages/tuhi-git/) 108 | 109 | Device notes 110 | ============ 111 | 112 | When following any device notes below, replace the example bluetooth 113 | addresses with your device's bluetooth address. 114 | 115 | Bamboo Spark 116 | ------------ 117 | 118 | The Bluetooth connection on the Bamboo Spark behaves differently depending 119 | on whether there are drawings pending or not. Generally, if no drawings are 120 | pending, it is harder to connect to the device. Save yourself the pain and 121 | make sure you have drawings pending while debugging. 122 | 123 | ### If the device has no drawings available: 124 | 125 | * start `bluetoothctl`, commands below are to be issued in its interactive shell 126 | * enable discovery mode (`scan on`) 127 | * hold the Bamboo Spark button until the blue light is flashing 128 | * You should see the device itself show up, but none of its services 129 | ``` 130 | [NEW] Device E2:43:03:67:0E:01 Bamboo Spark 131 | ``` 132 | * While the LED is still flashing, `connect E2:43:03:67:0E:01` 133 | ``` 134 | Attempting to connect to E2:43:03:67:0E:01 135 | [CHG] Device E2:43:03:67:0E:01 Connected: yes 136 | ... lots of services being resolved 137 | [CHG] Device E2:43:03:67:0E:01 ServicesResolved: yes 138 | [CHG] Device E2:43:03:67:0E:01 ServicesResolved: no 139 | [CHG] Device E2:43:03:67:0E:01 Connected: no 140 | ``` 141 | Note how the device disconnects again at the end. Doesn't matter, now you 142 | have the services cached. 143 | * Don't forget to eventually turn disable discovery mode off (`scan off`) 144 | 145 | Now you have the device cached in bluez and you can work with that data. 146 | However, you **cannot connect to the device while it has no drawings 147 | pending**. Running `connect` and pressing the Bamboo Spark button shortly 148 | does nothing. 149 | 150 | ### If the device has drawings available: 151 | 152 | * start `bluetoothctl`, commands below are to be issued in its interactive shell 153 | * enable discovery mode (`scan on`) 154 | * press the Bamboo Spark button shortly 155 | * You should see the device itself show up, but none of its services 156 | ``` 157 | [NEW] Device E2:43:03:67:0E:01 Bamboo Spark 158 | ``` 159 | * `connect E2:43:03:67:0E:01`, then press the Bamboo Spark button 160 | ``` 161 | Attempting to connect to E2:43:03:67:0E:01 162 | [CHG] Device E2:43:03:67:0E:01 Connected: yes 163 | ... lots of services being resolved 164 | [CHG] Device E2:43:03:67:0E:01 ServicesResolved: yes 165 | [CHG] Device E2:43:03:67:0E:01 ServicesResolved: no 166 | [CHG] Device E2:43:03:67:0E:01 Connected: no 167 | ``` 168 | Note how the device disconnects again at the end. Doesn't matter, now you 169 | have the services cached. 170 | * `connect E2:43:03:67:0E:01`, then press the Bamboo Spark button re-connects to the device 171 | The device will disconnect after approximately 10s. You need to start 172 | issuing the commands to talk to the controller before that happens. 173 | * Don't forget to eventually turn disable discovery mode off (`scan off`) 174 | 175 | You **must** run `connect` before pressing the button. Just pressing the 176 | button does nothing unless bluez is trying to connect to the device. 177 | 178 | **Warning**: A successful communication with the controller deletes the 179 | drawings from the controller, so you may not be able to re-connect. 180 | -------------------------------------------------------------------------------- /tools/parse_log.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | # This Python 2 program allows to translate btsnoop capture files to 15 | # raw data coming from the various endpoints. 16 | # 17 | # You need to retrieve a btsnoop capture file from Android: 18 | # * Set up your device you want to snoop with your Android phone 19 | # * Install some Android file manager 20 | # * Enable developer mode on your Android device 21 | # * In Settings - General - Developer Options, enable "Bluetooth HCI snoop 22 | # log". This will log all bluetooth traffic to a file 23 | # `/Android/data/btsnoop_hci.log` (the location may differ, search for it) 24 | # * Use the app to produce some bluetooth data you want to capture 25 | # * disable bluetooth snooping 26 | # * Copy the `btsnoop_hci.log` file into `Downloads`, connect the Android 27 | # device to a computer and download the file. Or mail it to yourself. Or 28 | # whatever other way you find to get that file onto your computer. 29 | 30 | from __future__ import print_function 31 | 32 | import sys 33 | import binascii 34 | 35 | # https://github.com/joekickass/python-btsnoop 36 | import btsnoop.btsnoop.btsnoop as btsnoop 37 | import btsnoop.bt.hci_uart as hci_uart 38 | import btsnoop.bt.hci_acl as hci_acl 39 | import btsnoop.bt.l2cap as l2cap 40 | import btsnoop.bt.att as att 41 | 42 | NORDIC_UART_SERVICE_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e' 43 | NORDIC_UART_CHRC_TX_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e' 44 | NORDIC_UART_CHRC_RX_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e' 45 | 46 | WACOM_LIVE_SERVICE_UUID = '00001523-1212-efde-1523-785feabcd123' 47 | WACOM_CHRC_LIVE_PEN_DATA_UUID = '00001524-1212-efde-1523-785feabcd123' 48 | 49 | WACOM_OFFLINE_SERVICE_UUID = 'ffee0001-bbaa-9988-7766-554433221100' 50 | WACOM_OFFLINE_FW_DATA_UUID = 'ffee0002-bbaa-9988-7766-554433221100' 51 | WACOM_OFFLINE_CHRC_PEN_DATA_UUID = 'ffee0003-bbaa-9988-7766-554433221100' 52 | 53 | MYSTERIOUS_NOTIFICATION_SERVICE_UUID = '3a340720-c572-11e5-86c5-0002a5d5c51b' 54 | MYSTERIOUS_NOTIFICATION_CHRC_UUID = '3a340721-c572-11e5-86c5-0002a5d5c51b' 55 | 56 | # http://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v7.x.x/doc/7.2.0/s110/html/a00071.html#ota_spec_sec 57 | NORDIC_DFU_SERVICE_UUID = '00001530-1212-efde-1523-785feabcd123' 58 | NORDIC_DFU_CTL_POINT_CHRC_UUID = '00001531-1212-efde-1523-785feabcd123' 59 | NORDIC_DFU_PACKET_CHRC_UUID = '00001532-1212-efde-1523-785feabcd123' 60 | NORDIC_DFU_UNKNONWN_CHRC_UUID = '00001534-1212-efde-1523-785feabcd123' 61 | 62 | desc_uuids = { 63 | NORDIC_UART_SERVICE_UUID: 'NORDIC_UART_SERVICE_UUID', 64 | NORDIC_UART_CHRC_TX_UUID: 'Nordic UART TX -->', 65 | NORDIC_UART_CHRC_RX_UUID: 'Nordic UART RX <--', 66 | 67 | NORDIC_DFU_SERVICE_UUID: 'NORDIC_DFU_SERVICE_UUID', 68 | NORDIC_DFU_CTL_POINT_CHRC_UUID: 'Nordic DFU Ctl Point', 69 | NORDIC_DFU_PACKET_CHRC_UUID: 'Nordic DFU packet', 70 | NORDIC_DFU_UNKNONWN_CHRC_UUID: 'Nordic DFU Unknown', 71 | 72 | WACOM_LIVE_SERVICE_UUID: 'WACOM_LIVE_SERVICE_UUID', 73 | WACOM_CHRC_LIVE_PEN_DATA_UUID: 'Wacom Live <----', 74 | 75 | WACOM_OFFLINE_SERVICE_UUID: 'WACOM_OFFLINE_SERVICE_UUID', 76 | WACOM_OFFLINE_FW_DATA_UUID: 'Sending FW Data --->', 77 | WACOM_OFFLINE_CHRC_PEN_DATA_UUID: 'Wacom RX <----', 78 | 79 | MYSTERIOUS_NOTIFICATION_SERVICE_UUID: 'MYSTERIOUS_NOTIFICATION_SERVICE_UUID', 80 | MYSTERIOUS_NOTIFICATION_CHRC_UUID: 'Mysterious Notification', 81 | } 82 | 83 | handles = {} 84 | 85 | 86 | def att_data_to_uuid(data): 87 | # reverse the string 88 | data = data[::-1] 89 | uuid = binascii.hexlify(data[:4]) + '-' + \ 90 | binascii.hexlify(data[4:6]) + '-' + \ 91 | binascii.hexlify(data[6:8]) + '-' + \ 92 | binascii.hexlify(data[8:10]) + '-' + \ 93 | binascii.hexlify(data[10:]) 94 | return uuid 95 | 96 | 97 | def get_rows(records): 98 | 99 | rows = [] 100 | for record in records: 101 | 102 | seq_nbr = record[0] 103 | # time = record[3].strftime("%b-%d %H:%M:%S.%f") 104 | 105 | hci_pkt_type, hci_pkt_data = hci_uart.parse(record[4]) 106 | # hci = hci_uart.type_to_str(hci_pkt_type) 107 | 108 | if hci_pkt_type != hci_uart.ACL_DATA: 109 | continue 110 | 111 | hci_data = hci_acl.parse(hci_pkt_data) 112 | l2cap_length, l2cap_cid, l2cap_data = l2cap.parse(hci_data[2], hci_data[4]) 113 | 114 | if l2cap_cid != l2cap.L2CAP_CID_ATT: 115 | continue 116 | 117 | att_opcode, att_data = att.parse(l2cap_data) 118 | # cmd_evt_l2cap = att.opcode_to_str(att_opcode) 119 | data = att_data 120 | 121 | if att_opcode == 0x11: 122 | length = ord(data[0]) 123 | if length == 20: 124 | start = binascii.hexlify(data[1:3]) 125 | end = binascii.hexlify(data[3:5]) 126 | print('{:>6} service handle from {} to {}: {} '.format(seq_nbr, start, end, att_data_to_uuid(data[5:]))) 127 | continue 128 | elif att_opcode == 0x09: 129 | length = ord(data[0]) 130 | if length == 21: 131 | value_handle = binascii.hexlify(data[4:6]) 132 | uuid = att_data_to_uuid(data[6:]) 133 | desc_uuid = uuid 134 | try: 135 | desc_uuid = desc_uuids[uuid] 136 | except KeyError: 137 | pass 138 | print('{:>6} chrc at handle {}: {}'.format(seq_nbr, value_handle, uuid)) 139 | handles[value_handle] = (uuid, desc_uuid) 140 | continue 141 | 142 | if att_opcode not in [0x52, 0x1b]: 143 | continue 144 | 145 | data = binascii.hexlify(data) 146 | 147 | handle = data[:4] 148 | if handle not in handles: 149 | continue 150 | 151 | rows.append(['{:>6}'.format(seq_nbr), handles[handle][1], data[4:]]) 152 | 153 | return rows 154 | 155 | 156 | def main(filename): 157 | records = btsnoop.parse(filename) 158 | rows = get_rows(records) 159 | 160 | for r in rows: 161 | print(' '.join(r)) 162 | 163 | 164 | if __name__ == "__main__": 165 | if len(sys.argv) == 2: 166 | main(sys.argv[1]) 167 | else: 168 | sys.exit(-1) 169 | -------------------------------------------------------------------------------- /po/tr.po: -------------------------------------------------------------------------------- 1 | # Turkish translation for tuhi. 2 | # Copyright © 2019 the tuhi authors. 3 | # This file is distributed under the same license as the tuhi package. 4 | # Gündüzhan Gündüz , 2021. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: tuhi\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2022-04-28 09:34+1000\n" 11 | "PO-Revision-Date: \n" 12 | "Last-Translator: Gündüzhan Gündüz \n" 13 | "Language-Team: Turkish \n" 14 | "Language: tr\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " 19 | "|| n%100>=20) ? 1 : 2);\n" 20 | "X-Generator: Poedit 2.4.1\n" 21 | 22 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:7 23 | #: data/org.freedesktop.Tuhi.desktop.in:3 24 | msgid "Tuhi" 25 | msgstr "Tuhi" 26 | 27 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:8 28 | #: data/org.freedesktop.Tuhi.desktop.in:4 29 | msgid "Utility to download drawings from the Wacom Ink range of devices" 30 | msgstr "Wacom Ink cihazlarınızdan çizimlerinizi indirmek için bir araç" 31 | 32 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:10 33 | msgid "" 34 | "Tuhi is a graphical user interface to download drawings stored on tablet " 35 | "devices from the Wacom Ink range, e.g. Intuos Pro Paper or Bamboo Slate." 36 | msgstr "" 37 | "Tuhi, Wacom Ink serisi cihazlarda depolanan çizimlerinizi indirmenize olanak " 38 | "sağlayan bir grafik kullanıcı arayüzüdür. Ör. Intuos Pro Paper veya Bamboo " 39 | "Slate." 40 | 41 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:15 42 | #, fuzzy 43 | msgid "" 44 | "Tuhi requires Tuhi, the daemon to actually communicate with the devices. " 45 | "ThiGui is merely a front end to Tuhi, Tuhi must be installed and running " 46 | "when Tuhi is launched." 47 | msgstr "" 48 | "Tuhi cihazınız ile iletişim kurmak için arka plan programı Tuhi'ye ihtiyaç " 49 | "duyar. ThiGui, Tuhi için sadece bir başlangıç aşamasıdır. Tuhi " 50 | "başlatıldığında kurulmalı ve çalıştırılmalıdır." 51 | 52 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:32 53 | msgid "Tuhi's main window" 54 | msgstr "Tuhi ana penceresi" 55 | 56 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:36 57 | msgid "Tuhi's main window (zoomed)" 58 | msgstr "Tuhi ana penceresi (yakın)" 59 | 60 | #. Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! 61 | #: data/org.freedesktop.Tuhi.desktop.in:12 62 | msgid "tablet;wacom;ink;" 63 | msgstr "tablet;wacom;ink;" 64 | 65 | #: data/ui/AboutDialog.ui.in:13 66 | msgid "Visit Tuhi’s website" 67 | msgstr "Tuhi'nin sitesini ziyaret edin" 68 | 69 | #: data/ui/AppMenu.ui:5 70 | msgid "Portrait" 71 | msgstr "Portre" 72 | 73 | #: data/ui/AppMenu.ui:10 74 | msgid "Landscape" 75 | msgstr "Yatay" 76 | 77 | #: data/ui/AppMenu.ui:17 78 | msgid "Help" 79 | msgstr "Yardım" 80 | 81 | #: data/ui/AppMenu.ui:21 82 | msgid "About" 83 | msgstr "Hakkında" 84 | 85 | #: data/ui/DrawingPerspective.ui:68 86 | msgid "Undo delete drawing" 87 | msgstr "Silinen çizimi geri al" 88 | 89 | #: data/ui/DrawingPerspective.ui:132 90 | msgid "Press the button on the device to synchronize drawings" 91 | msgstr "Çizimlerinizi senkron etmek için cihazdaki düğmeye basın" 92 | 93 | #: data/ui/ErrorPerspective.ui:21 94 | #, fuzzy 95 | msgid "" 96 | "TuhiGUI is an interactive GUI to download data from Tuhi.\n" 97 | "\n" 98 | "Tuhi connects to tablets of the Wacom Ink range. It allows you to download " 99 | "the drawings stored on those devices as SVGs for processing later.\n" 100 | "\n" 101 | "Tuhi is a DBus server that needs to be running for the Tuhi GUI to connect " 102 | "to it. Connecting to the DBus server should take less than a second. If you " 103 | "read this far, your Tuhi DBus server is not running or responding and needs " 104 | "to be restarted." 105 | msgstr "" 106 | "TuhiGUI, Tuhi'den çizimlerinizi indirmek için bir araçtır.\n" 107 | "\n" 108 | "Tuhi Wacom Ink serisi cihazlara bağlanır. Ayrıca cihazlarda depolanan " 109 | "çizimlerinizi işlemek üzere SVG olarak indirmenizi sağlar.\n" 110 | "\n" 111 | "Tuhi, TuhiGUI'nin bağlanabilmesi için çalışan bir DBus sunucusudur. DBus " 112 | "sunucusuna bağlanmak saniyeden çok daha kısa sürede gerçekleşir. Eğer buraya " 113 | "kadar okuduysanız, Tuhi DBus sunucusu çalışmıyor, yanıt vermiyor ve yeniden " 114 | "başlatılması gerekiyordur." 115 | 116 | #: data/ui/ErrorPerspective.ui:69 117 | msgid "Connecting to Tuhi" 118 | msgstr "Tuhi'ye bağlanılıyor" 119 | 120 | #: data/ui/ErrorPerspective.ui:96 121 | msgid "" 122 | "This should take less than a second. Make sure the Tuhi DBus server is " 123 | "running." 124 | msgstr "" 125 | "Bir saniyeden daha kısa sürer. Tuhi DBus sunucusunun çalıştığından emin olun." 126 | 127 | #: data/ui/MainWindow.ui:166 128 | msgid "Authorization error while connecting to the device " 129 | msgstr "Cihaza bağlanırken yetkilendirme hatası oluştu " 130 | 131 | #: data/ui/MainWindow.ui:176 132 | msgid "Register" 133 | msgstr "Kayıt" 134 | 135 | #: data/ui/SetupPerspective.ui:7 136 | msgid "Initial Device Setup" 137 | msgstr "İlk Cihaz Kurulumu" 138 | 139 | #: data/ui/SetupPerspective.ui:30 140 | msgid "Quit" 141 | msgstr "Çıkış" 142 | 143 | #: data/ui/SetupPerspective.ui:70 144 | msgid "Hold the button on the device until the blue light is flashing." 145 | msgstr "Mavı ışık yanıp sönmeye başlayana kadar düğmeye basılı tutun." 146 | 147 | #: data/ui/SetupPerspective.ui:103 148 | msgid "Searching for device" 149 | msgstr "Cihaz aranıyor" 150 | 151 | #: data/ui/SetupPerspective.ui:137 152 | msgid "Connecting to LE Paper" 153 | msgstr "LE Paper'a bağlanıyor" 154 | 155 | #: data/ui/SetupPerspective.ui:170 156 | msgid "Connecting to device..." 157 | msgstr "Cihaza bağlanıyor ..." 158 | 159 | #: data/ui/SetupPerspective.ui:206 160 | msgid "Press the button on the device now!" 161 | msgstr "Şimdi cihazdaki düğmeye basın!" 162 | 163 | #: data/ui/SetupPerspective.ui:240 164 | msgid "waiting for reply" 165 | msgstr "yanıt bekleniyor" 166 | 167 | #. Translators: the default filename to save to 168 | #: tuhi/gui/drawing.py:121 169 | msgid "untitled.svg" 170 | msgstr "yenibelge.svg" 171 | 172 | #. Translators: filter name to show all/any files 173 | #: tuhi/gui/drawing.py:125 174 | msgid "Any files" 175 | msgstr "Herhangi bir dosya" 176 | 177 | #. Translators: filter to show svg files only 178 | #: tuhi/gui/drawing.py:129 179 | msgid "SVG files" 180 | msgstr "SVG dosyaları" 181 | 182 | #. Translators: filter to show png files only 183 | #: tuhi/gui/drawing.py:133 184 | msgid "PNG files" 185 | msgstr "PNG dosyaları" 186 | 187 | #: tuhi/gui/window.py:68 188 | #, python-brace-format 189 | msgid "Connecting to {device.name}" 190 | msgstr "{device.name} cihaz bağlantısı kuruluyor" 191 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('tuhi', 2 | version: '0.6', 3 | license: 'GPLv2', 4 | meson_version: '>= 0.50.0') 5 | # The tag date of the project_version(), update when the version bumps. 6 | version_date='2022-04-28' 7 | 8 | # Dependencies 9 | dependency('pygobject-3.0', version: '>= 3.30', required: true) 10 | 11 | prefix = get_option('prefix') 12 | datadir = join_paths(prefix, get_option('datadir')) 13 | localedir = join_paths(prefix, get_option('localedir')) 14 | pkgdatadir = join_paths(datadir, meson.project_name()) 15 | bindir = join_paths(prefix, get_option('bindir')) 16 | podir = join_paths(meson.source_root(), 'po') 17 | desktopdir = join_paths(datadir, 'applications') 18 | icondir = join_paths(datadir, 'icons', 'hicolor') 19 | icondir_scalable = join_paths(icondir, 'scalable', 'apps') 20 | icondir_symbolic = join_paths(icondir, 'symbolic', 'apps') 21 | metainfodir = join_paths(datadir, 'metainfo') 22 | libexecdir = join_paths(get_option('prefix'), get_option('libexecdir'), 'tuhi') 23 | 24 | 25 | i18n = import('i18n') 26 | # Workaround for https://github.com/mesonbuild/meson/issues/6165 27 | find_program('gettext') 28 | 29 | subdir('po') 30 | subdir('data') 31 | 32 | pymod = import('python') 33 | 34 | # external python modules that are required for running Tuhi 35 | python_modules = [ 36 | 'svgwrite', 37 | 'xdg', 38 | 'gi', 39 | 'cairo', 40 | ] 41 | if meson.version().version_compare('>=0.51') 42 | py3 = pymod.find_installation(modules: python_modules) 43 | else 44 | py3 = pymod.find_installation() 45 | 46 | foreach module: python_modules 47 | if run_command(py3, '-c', 'import @0@'.format(module)).returncode() != 0 48 | error('Failed to find required python module \'@0@\'.'.format(module)) 49 | endif 50 | endforeach 51 | endif 52 | python_dir = py3.get_install_dir() 53 | install_subdir('tuhi', 54 | install_dir: python_dir, 55 | exclude_directories: '__pycache__') 56 | 57 | # We have three startup scripts: 58 | # - tuhi: starts server and GUI 59 | # - tuhi-gui: starts the GUI only 60 | # - tuhi-server: starts the server only 61 | # 62 | # tuhi-server can run as-is, we don't need meson for it. But for the other 63 | # two we build a {name}.devel version that uses the in-tree files. 64 | # For that we need to replace a few paths, in the installed versions we just 65 | # use the normal dirs. 66 | # 67 | config_tuhi = configuration_data() 68 | config_tuhi.set('libexecdir', libexecdir) 69 | config_tuhi.set('devel', '') 70 | 71 | config_tuhi_devel = configuration_data() 72 | config_tuhi_devel.set('libexecdir', '') 73 | config_tuhi_devel.set('devel', ''' 74 | tuhi_gui = '@1@/tuhi-gui.devel' 75 | tuhi_server = '@0@/tuhi-server.py' 76 | print('Running from source tree, using local files') 77 | '''.format(meson.source_root(), meson.build_root())) 78 | 79 | config_tuhigui = configuration_data() 80 | config_tuhigui.set('pkgdatadir', pkgdatadir) 81 | config_tuhigui.set('localedir', localedir) 82 | config_tuhigui.set('devel', '') 83 | 84 | config_tuhigui_devel = config_tuhigui 85 | config_tuhigui_devel.set('pkgdatadir', join_paths(meson.build_root(), 'data')) 86 | config_tuhigui_devel.set('localedir', join_paths(meson.build_root(), 'po')) 87 | config_tuhigui_devel.set('devel', ''' 88 | sys.path.insert(1, '@0@') 89 | print('Running from source tree, using local files') 90 | '''.format(meson.source_root(), meson.build_root())) 91 | 92 | configure_file(input: 'tuhi.in', 93 | output: 'tuhi', 94 | configuration: config_tuhi, 95 | install_dir: bindir) 96 | 97 | configure_file(input: 'tuhi.in', 98 | output: 'tuhi.devel', 99 | configuration: config_tuhi_devel) 100 | 101 | configure_file(input: 'tuhi-gui.in', 102 | output: 'tuhi-gui', 103 | configuration: config_tuhigui, 104 | install_dir: libexecdir) 105 | 106 | configure_file(input: 'tuhi-gui.in', 107 | output: 'tuhi-gui.devel', 108 | configuration: config_tuhigui_devel) 109 | 110 | configure_file(input: 'tuhi-server.py', 111 | output: 'tuhi-server', 112 | copy: true, 113 | install_dir: libexecdir) 114 | 115 | meson.add_install_script('meson_install.sh') 116 | 117 | desktop_file = i18n.merge_file(input: 'data/org.freedesktop.Tuhi.desktop.in', 118 | output: 'org.freedesktop.Tuhi.desktop', 119 | type: 'desktop', 120 | po_dir: podir, 121 | install: true, 122 | install_dir: desktopdir) 123 | 124 | conf = configuration_data() 125 | conf.set('version', meson.project_version()) 126 | conf.set('url', 'https://github.com/tuhiproject/tuhi') 127 | conf.set('version_date', version_date) 128 | 129 | appdata_intl = configure_file(input: 'data/org.freedesktop.Tuhi.appdata.xml.in.in', 130 | output: 'org.freedesktop.Tuhi.appdata.xml.in', 131 | configuration: conf) 132 | 133 | appdata = i18n.merge_file(input: appdata_intl, 134 | output: 'org.freedesktop.Tuhi.appdata.xml', 135 | type: 'xml', 136 | po_dir: podir, 137 | install: true, 138 | install_dir: metainfodir) 139 | 140 | install_data('data/org.freedesktop.Tuhi.svg', install_dir: icondir) 141 | 142 | flake8 = find_program('flake8-3', 'flake8', required: false) 143 | if flake8.found() 144 | test('flake8', flake8, 145 | args: ['--ignore=E501,W504', 146 | join_paths(meson.source_root(), 'tuhi'), 147 | join_paths(meson.source_root(), 'tuhi', 'gui')]) 148 | test('flake8-tools', flake8, 149 | args: ['--ignore=E501,W504', 150 | join_paths(meson.source_root(), 'tools')]) 151 | # the tests need different flake exclusions 152 | test('flake8-tests', flake8, 153 | args: ['--ignore=E501,W504,F403,F405', 154 | join_paths(meson.source_root(), 'test/')]) 155 | endif 156 | 157 | desktop_validate = find_program('desktop-file-validate', required: false) 158 | if desktop_validate.found() 159 | test('desktop-file-validate', desktop_validate, args: [desktop_file]) 160 | endif 161 | 162 | appstreamcli = find_program('appstreamcli', required: false) 163 | if appstreamcli.found() 164 | test('appstreamcli validate', appstreamcli, 165 | args: ['validate', '--no-net', '--explain', appdata]) 166 | endif 167 | 168 | pytest = find_program('pytest-3', required: false) 169 | if pytest.found() 170 | test('unittest', pytest, 171 | args: [join_paths(meson.source_root(), 'test')], 172 | timeout: 180) 173 | endif 174 | 175 | # A wrapper to start tuhi at the same time as tuhigui, used by the flatpak 176 | configure_file(input: 'tools/tuhi-gui-flatpak.py', 177 | output: 'tuhi-gui-flatpak.py', 178 | copy: true) 179 | -------------------------------------------------------------------------------- /po/pl.po: -------------------------------------------------------------------------------- 1 | # Polish translation for tuhi. 2 | # Copyright © 2019 the tuhi authors. 3 | # This file is distributed under the same license as the tuhi package. 4 | # Piotr Drąg , 2019. 5 | # Aviary.pl , 2019. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: tuhi\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-04-28 09:34+1000\n" 12 | "PO-Revision-Date: 2019-10-24 16:23+0200\n" 13 | "Last-Translator: Piotr Drąg \n" 14 | "Language-Team: Polish \n" 15 | "Language: pl\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=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " 20 | "|| n%100>=20) ? 1 : 2);\n" 21 | 22 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:7 23 | #: data/org.freedesktop.Tuhi.desktop.in:3 24 | msgid "Tuhi" 25 | msgstr "Tuhi" 26 | 27 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:8 28 | #: data/org.freedesktop.Tuhi.desktop.in:4 29 | msgid "Utility to download drawings from the Wacom Ink range of devices" 30 | msgstr "Narzędzie do pobierania rysunków z rodziny urządzeń Wacom Ink" 31 | 32 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:10 33 | msgid "" 34 | "Tuhi is a graphical user interface to download drawings stored on tablet " 35 | "devices from the Wacom Ink range, e.g. Intuos Pro Paper or Bamboo Slate." 36 | msgstr "" 37 | "Tuhi to graficzny interfejs użytkownika do pobierania rysunków " 38 | "przechowywanych na tabletach z rodziny Wacom Ink, np. Intuos Pro Paper " 39 | "i Bamboo Slate." 40 | 41 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:15 42 | msgid "" 43 | "Tuhi requires Tuhi, the daemon to actually communicate with the devices. " 44 | "ThiGui is merely a front end to Tuhi, Tuhi must be installed and running " 45 | "when Tuhi is launched." 46 | msgstr "" 47 | "TuhiGUI wymaga Tuhi, usługi komunikującej się z urządzeniem. TuhiGUI to " 48 | "interfejs dla usługi Tuhi, która musi być zainstalowana i uruchomiona, aby " 49 | "narzędzie Tuhi mogło działać." 50 | 51 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:32 52 | msgid "Tuhi's main window" 53 | msgstr "Główne okno Tuhi" 54 | 55 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:36 56 | msgid "Tuhi's main window (zoomed)" 57 | msgstr "Główne okno Tuhi (powiększone)" 58 | 59 | #. Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! 60 | #: data/org.freedesktop.Tuhi.desktop.in:12 61 | msgid "tablet;wacom;ink;" 62 | msgstr "tablet;wacom;ink;" 63 | 64 | #: data/ui/AboutDialog.ui.in:13 65 | msgid "Visit Tuhi’s website" 66 | msgstr "Witryna programu Tuhi" 67 | 68 | #: data/ui/AppMenu.ui:5 69 | msgid "Portrait" 70 | msgstr "Pionowo" 71 | 72 | #: data/ui/AppMenu.ui:10 73 | msgid "Landscape" 74 | msgstr "Poziomo" 75 | 76 | #: data/ui/AppMenu.ui:17 77 | msgid "Help" 78 | msgstr "Pomoc" 79 | 80 | #: data/ui/AppMenu.ui:21 81 | msgid "About" 82 | msgstr "O programie" 83 | 84 | #: data/ui/DrawingPerspective.ui:68 85 | msgid "Undo delete drawing" 86 | msgstr "Cofnij usunięcie rysunku" 87 | 88 | #: data/ui/DrawingPerspective.ui:132 89 | msgid "Press the button on the device to synchronize drawings" 90 | msgstr "Proszę nacisnąć przycisk na urządzeniu, aby zsynchronizować rysunki" 91 | 92 | #: data/ui/ErrorPerspective.ui:21 93 | #, fuzzy 94 | msgid "" 95 | "TuhiGUI is an interactive GUI to download data from Tuhi.\n" 96 | "\n" 97 | "Tuhi connects to tablets of the Wacom Ink range. It allows you to download " 98 | "the drawings stored on those devices as SVGs for processing later.\n" 99 | "\n" 100 | "Tuhi is a DBus server that needs to be running for the Tuhi GUI to connect " 101 | "to it. Connecting to the DBus server should take less than a second. If you " 102 | "read this far, your Tuhi DBus server is not running or responding and needs " 103 | "to be restarted." 104 | msgstr "" 105 | "TuhiGUI to interaktywny interfejs użytkownika do pobierania danych z usługi " 106 | "Tuhi.\n" 107 | "\n" 108 | "Tuhi łączy się z tabletami z rodziny Wacom Ink. Umożliwia pobieranie obrazów " 109 | "przechowywanych na tych urządzeniach jako pliki SVG do przetwarzania " 110 | "w późniejszym czasie.\n" 111 | "\n" 112 | "Tuhi to serwer D-Bus, który musi być uruchomiony, aby interfejs Tuhi mógł " 113 | "się z nim połączyć. Połączenie z serwerem D-Bus powinno zająć mniej niż " 114 | "sekundę. Jeśli jeszcze czytasz ten tekst, to znaczy że serwer D-Bus Tuhi nie " 115 | "jest uruchomiony lub nie odpowiada i wymaga ponownego uruchomienia." 116 | 117 | #: data/ui/ErrorPerspective.ui:69 118 | msgid "Connecting to Tuhi" 119 | msgstr "Łączenie z usługą Tuhi" 120 | 121 | #: data/ui/ErrorPerspective.ui:96 122 | msgid "" 123 | "This should take less than a second. Make sure the Tuhi DBus server is " 124 | "running." 125 | msgstr "" 126 | "To powinno zająć mniej niż sekundę. Proszę się upewnić, że serwer D-Bus Tuhi " 127 | "jest uruchomiony." 128 | 129 | #: data/ui/MainWindow.ui:166 130 | msgid "Authorization error while connecting to the device " 131 | msgstr "Błąd upoważnienia podczas łączenia z urządzeniem " 132 | 133 | #: data/ui/MainWindow.ui:176 134 | msgid "Register" 135 | msgstr "Zarejestruj" 136 | 137 | #: data/ui/SetupPerspective.ui:7 138 | msgid "Initial Device Setup" 139 | msgstr "Pierwsza konfiguracja urządzenia" 140 | 141 | #: data/ui/SetupPerspective.ui:30 142 | msgid "Quit" 143 | msgstr "Zakończ" 144 | 145 | #: data/ui/SetupPerspective.ui:70 146 | msgid "Hold the button on the device until the blue light is flashing." 147 | msgstr "" 148 | "Proszę przytrzymać przycisk na urządzeniu, aż niebieska dioda zacznie migać." 149 | 150 | #: data/ui/SetupPerspective.ui:103 151 | msgid "Searching for device" 152 | msgstr "Wyszukiwanie urządzenia" 153 | 154 | #: data/ui/SetupPerspective.ui:137 155 | msgid "Connecting to LE Paper" 156 | msgstr "Łączenie z LE Paper" 157 | 158 | #: data/ui/SetupPerspective.ui:170 159 | msgid "Connecting to device..." 160 | msgstr "Łączenie z urządzeniem…" 161 | 162 | #: data/ui/SetupPerspective.ui:206 163 | msgid "Press the button on the device now!" 164 | msgstr "Proszę teraz nacisnąć przycisk na urządzeniu." 165 | 166 | #: data/ui/SetupPerspective.ui:240 167 | msgid "waiting for reply" 168 | msgstr "oczekiwanie na odpowiedź" 169 | 170 | #. Translators: the default filename to save to 171 | #: tuhi/gui/drawing.py:121 172 | msgid "untitled.svg" 173 | msgstr "bez tytułu.svg" 174 | 175 | #. Translators: filter name to show all/any files 176 | #: tuhi/gui/drawing.py:125 177 | msgid "Any files" 178 | msgstr "Wszystkie pliki" 179 | 180 | #. Translators: filter to show svg files only 181 | #: tuhi/gui/drawing.py:129 182 | msgid "SVG files" 183 | msgstr "Pliki SVG" 184 | 185 | #. Translators: filter to show png files only 186 | #: tuhi/gui/drawing.py:133 187 | msgid "PNG files" 188 | msgstr "Pliki PNG" 189 | 190 | #: tuhi/gui/window.py:68 191 | #, python-brace-format 192 | msgid "Connecting to {device.name}" 193 | msgstr "Łączenie z urządzeniem {device.name}" 194 | -------------------------------------------------------------------------------- /po/it.po: -------------------------------------------------------------------------------- 1 | # Italian translation for tuhi. 2 | # Copyright © 2019 the tuhi authors. 3 | # This file is distributed under the same license as the tuhi package. 4 | # ALBANO BATTISTELLA , 2020,2022. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: tuhi\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2022-04-28 09:34+1000\n" 11 | "PO-Revision-Date: 2022-04-25 18:23+0100\n" 12 | "Last-Translator: Albano Battistella \n" 13 | "Language-Team: Italian <>\n" 14 | "Language: it\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " 19 | "|| n%100>=20) ? 1 : 2);\n" 20 | 21 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:7 22 | #: data/org.freedesktop.Tuhi.desktop.in:3 23 | msgid "Tuhi" 24 | msgstr "Tuhi" 25 | 26 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:8 27 | #: data/org.freedesktop.Tuhi.desktop.in:4 28 | msgid "Utility to download drawings from the Wacom Ink range of devices" 29 | msgstr "" 30 | "Utilità per scaricare i disegni dalla gamma Inchiostro di dispositivi Wacom" 31 | 32 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:10 33 | msgid "" 34 | "Tuhi is a graphical user interface to download drawings stored on tablet " 35 | "devices from the Wacom Ink range, e.g. Intuos Pro Paper or Bamboo Slate." 36 | msgstr "" 37 | "Tuhi è un'interfaccia utente grafica per scaricare disegni archiviati su " 38 | "tablet dispositivi della gamma di Inchiostro Wacom, ad esempio Intuos Pro " 39 | "Paper o Bamboo Slate." 40 | 41 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:15 42 | #, fuzzy 43 | msgid "" 44 | "Tuhi requires Tuhi, the daemon to actually communicate with the devices. " 45 | "ThiGui is merely a front end to Tuhi, Tuhi must be installed and running " 46 | "when Tuhi is launched." 47 | msgstr "" 48 | "Tuhi richiede Tuhi, il demone per comunicare effettivamente con i " 49 | "dispositivi.ThiGui è semplicemente un front-end di Tuhi, Tuhi deve essere " 50 | "installato e funzionante quando Tuhi viene lanciato." 51 | 52 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:32 53 | msgid "Tuhi's main window" 54 | msgstr "Finestra principale di Tuhi" 55 | 56 | #: data/org.freedesktop.Tuhi.appdata.xml.in.in:36 57 | msgid "Tuhi's main window (zoomed)" 58 | msgstr "Finestra principale di Tuhi (ingrandita)" 59 | 60 | #. Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! 61 | #: data/org.freedesktop.Tuhi.desktop.in:12 62 | msgid "tablet;wacom;ink;" 63 | msgstr "tablet;wacom;inchiostro;" 64 | 65 | #: data/ui/AboutDialog.ui.in:13 66 | msgid "Visit Tuhi’s website" 67 | msgstr "Visita il sito web di Tuhi" 68 | 69 | #: data/ui/AppMenu.ui:5 70 | msgid "Portrait" 71 | msgstr "Ritratto" 72 | 73 | #: data/ui/AppMenu.ui:10 74 | msgid "Landscape" 75 | msgstr "Paesaggio" 76 | 77 | #: data/ui/AppMenu.ui:17 78 | msgid "Help" 79 | msgstr "Aiuto" 80 | 81 | #: data/ui/AppMenu.ui:21 82 | msgid "About" 83 | msgstr "Informazioni" 84 | 85 | #: data/ui/DrawingPerspective.ui:68 86 | msgid "Undo delete drawing" 87 | msgstr "Annulla cancellazione del disegno" 88 | 89 | #: data/ui/DrawingPerspective.ui:132 90 | msgid "Press the button on the device to synchronize drawings" 91 | msgstr "Premere il pulsante sul dispositivo per sincronizzare i disegni" 92 | 93 | #: data/ui/ErrorPerspective.ui:21 94 | #, fuzzy 95 | msgid "" 96 | "TuhiGUI is an interactive GUI to download data from Tuhi.\n" 97 | "\n" 98 | "Tuhi connects to tablets of the Wacom Ink range. It allows you to download " 99 | "the drawings stored on those devices as SVGs for processing later.\n" 100 | "\n" 101 | "Tuhi is a DBus server that needs to be running for the Tuhi GUI to connect " 102 | "to it. Connecting to the DBus server should take less than a second. If you " 103 | "read this far, your Tuhi DBus server is not running or responding and needs " 104 | "to be restarted." 105 | msgstr "" 106 | "TuhiGUI è una GUI interattiva per scaricare dati da Tuhi.\n" 107 | "\n" 108 | "Tuhi si collega ai tablet della gamma Wacom. Ti permette di scaricare i " 109 | "disegni memorizzati su tali dispositivi come SVG per l'elaborazione " 110 | "successiva.\n" 111 | "\n" 112 | "Tuhi è un server DBus che deve essere in esecuzione perché la GUI Tuhi possa " 113 | "connettersi ad esso. La connessione al server DBus dovrebbe richiedere meno " 114 | "di un secondo. Se hai letto fin qui, il tuo server Tuhi DBus non è in " 115 | "esecuzione o non risponde e ha bisogno di essere riavviato." 116 | 117 | #: data/ui/ErrorPerspective.ui:69 118 | msgid "Connecting to Tuhi" 119 | msgstr "Connessione a Tuhi" 120 | 121 | #: data/ui/ErrorPerspective.ui:96 122 | msgid "" 123 | "This should take less than a second. Make sure the Tuhi DBus server is " 124 | "running." 125 | msgstr "" 126 | "Questo dovrebbe richiedere meno di un secondo. Assicurati che il server Tuhi " 127 | "DBus sia in esecuzione." 128 | 129 | #: data/ui/MainWindow.ui:166 130 | msgid "Authorization error while connecting to the device " 131 | msgstr "Errore di autorizzazione durante la connessione al dispositivo " 132 | 133 | #: data/ui/MainWindow.ui:176 134 | msgid "Register" 135 | msgstr "Registro" 136 | 137 | #: data/ui/SetupPerspective.ui:7 138 | msgid "Initial Device Setup" 139 | msgstr "Configurazione iniziale del dispositivo" 140 | 141 | #: data/ui/SetupPerspective.ui:30 142 | msgid "Quit" 143 | msgstr "Esci" 144 | 145 | #: data/ui/SetupPerspective.ui:70 146 | msgid "Hold the button on the device until the blue light is flashing." 147 | msgstr "" 148 | "Tieni premuto il pulsante sul dispositivo finché la luce blu non lampeggia." 149 | 150 | #: data/ui/SetupPerspective.ui:103 151 | msgid "Searching for device" 152 | msgstr "Ricerca del dispositivo" 153 | 154 | #: data/ui/SetupPerspective.ui:137 155 | msgid "Connecting to LE Paper" 156 | msgstr "Collegamento a LE Paper" 157 | 158 | #: data/ui/SetupPerspective.ui:170 159 | msgid "Connecting to device..." 160 | msgstr "Connessione al dispositivo..." 161 | 162 | #: data/ui/SetupPerspective.ui:206 163 | msgid "Press the button on the device now!" 164 | msgstr "Premi ora il pulsante sul dispositivo!" 165 | 166 | #: data/ui/SetupPerspective.ui:240 167 | msgid "waiting for reply" 168 | msgstr "in attesa di risposta" 169 | 170 | #. Translators: the default filename to save to 171 | #: tuhi/gui/drawing.py:121 172 | msgid "untitled.svg" 173 | msgstr "senza titolo.svg" 174 | 175 | #. Translators: filter name to show all/any files 176 | #: tuhi/gui/drawing.py:125 177 | msgid "Any files" 178 | msgstr "Qualsiasi file" 179 | 180 | #. Translators: filter to show svg files only 181 | #: tuhi/gui/drawing.py:129 182 | msgid "SVG files" 183 | msgstr "File SVG" 184 | 185 | #. Translators: filter to show png files only 186 | #: tuhi/gui/drawing.py:133 187 | msgid "PNG files" 188 | msgstr "File PNG" 189 | 190 | #: tuhi/gui/window.py:68 191 | #, python-brace-format 192 | msgid "Connecting to {device.name}" 193 | msgstr "Connessione a {device.name}" 194 | -------------------------------------------------------------------------------- /tuhi/uhid.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation; either version 2 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | # 17 | 18 | from gi.repository import GObject 19 | import os 20 | import struct 21 | import uuid 22 | 23 | 24 | class UHIDUncompleteException(Exception): 25 | pass 26 | 27 | 28 | class UHIDDevice(GObject.Object): 29 | __UHID_LEGACY_CREATE = 0 30 | UHID_DESTROY = 1 31 | UHID_START = 2 32 | UHID_STOP = 3 33 | UHID_OPEN = 4 34 | UHID_CLOSE = 5 35 | UHID_OUTPUT = 6 36 | __UHID_LEGACY_OUTPUT_EV = 7 37 | __UHID_LEGACY_INPUT = 8 38 | UHID_GET_REPORT = 9 39 | UHID_GET_REPORT_REPLY = 10 40 | UHID_CREATE2 = 11 41 | UHID_INPUT2 = 12 42 | UHID_SET_REPORT = 13 43 | UHID_SET_REPORT_REPLY = 14 44 | 45 | UHID_FEATURE_REPORT = 0 46 | UHID_OUTPUT_REPORT = 1 47 | UHID_INPUT_REPORT = 2 48 | 49 | def __init__(self, fd=None): 50 | GObject.Object.__init__(self) 51 | self._name = None 52 | self._phys = '' 53 | self._rdesc = None 54 | self.parsed_rdesc = None 55 | self._info = None 56 | if fd is None: 57 | self._fd = os.open('/dev/uhid', os.O_RDWR) 58 | else: 59 | self._fd = fd 60 | self.uniq = f'uhid_{str(uuid.uuid4())}' 61 | 62 | def __enter__(self): 63 | return self 64 | 65 | def __exit__(self, *exc_details): 66 | os.close(self._fd) 67 | 68 | @GObject.Property 69 | def fd(self): 70 | return self._fd 71 | 72 | @GObject.Property 73 | def rdesc(self): 74 | return self._rdesc 75 | 76 | @rdesc.setter 77 | def rdesc(self, rdesc): 78 | self._rdesc = rdesc 79 | 80 | @GObject.Property 81 | def phys(self): 82 | return self._phys 83 | 84 | @phys.setter 85 | def phys(self, phys): 86 | self._phys = phys 87 | 88 | @GObject.Property 89 | def name(self): 90 | return self._name 91 | 92 | @name.setter 93 | def name(self, name): 94 | self._name = name 95 | 96 | @GObject.Property 97 | def info(self): 98 | return self._info 99 | 100 | @info.setter 101 | def info(self, info): 102 | self._info = info 103 | 104 | @GObject.Property 105 | def bus(self): 106 | return self._info[0] 107 | 108 | @GObject.Property 109 | def vid(self): 110 | return self._info[1] 111 | 112 | @GObject.Property 113 | def pid(self): 114 | return self._info[2] 115 | 116 | def call_set_report(self, req, err): 117 | buf = struct.pack('< L L H', 118 | UHIDDevice.UHID_SET_REPORT_REPLY, 119 | req, 120 | err) 121 | os.write(self._fd, buf) 122 | 123 | def call_get_report(self, req, data, err): 124 | data = bytes(data) 125 | buf = struct.pack('< L L H H 4096s', 126 | UHIDDevice.UHID_GET_REPORT_REPLY, 127 | req, 128 | err, 129 | len(data), 130 | data) 131 | os.write(self._fd, buf) 132 | 133 | def call_input_event(self, data): 134 | data = bytes(data) 135 | buf = struct.pack('< L H 4096s', 136 | UHIDDevice.UHID_INPUT2, 137 | len(data), 138 | data) 139 | os.write(self._fd, buf) 140 | 141 | def create_kernel_device(self): 142 | if (self._name is None or 143 | self._rdesc is None or 144 | self._info is None): 145 | raise UHIDUncompleteException("missing uhid initialization") 146 | 147 | buf = struct.pack('< L 128s 64s 64s H H L L L L 4096s', 148 | UHIDDevice.UHID_CREATE2, 149 | bytes(self._name, 'utf-8'), # name 150 | bytes(self._phys, 'utf-8'), # phys 151 | bytes(self.uniq, 'utf-8'), # uniq 152 | len(self._rdesc), # rd_size 153 | self.bus, # bus 154 | self.vid, # vendor 155 | self.pid, # product 156 | 0, # version 157 | 0, # country 158 | bytes(self._rdesc)) # rd_data[HID_MAX_DESCRIPTOR_SIZE] 159 | 160 | n = os.write(self._fd, buf) 161 | assert n == len(buf) 162 | self.ready = True 163 | 164 | def destroy(self): 165 | self.ready = False 166 | buf = struct.pack('< L', 167 | UHIDDevice.UHID_DESTROY) 168 | os.write(self._fd, buf) 169 | 170 | def start(self, flags): 171 | print('start') 172 | 173 | def stop(self): 174 | print('stop') 175 | 176 | def open(self): 177 | print('open', self.sys_path) 178 | 179 | def close(self): 180 | print('close') 181 | 182 | def set_report(self, req, rnum, rtype, size, data): 183 | print('set report', req, rtype, size, [f'{d:02x}' for d in data[:size]]) 184 | self.call_set_report(req, 1) 185 | 186 | def get_report(self, req, rnum, rtype): 187 | print('get report', req, rnum, rtype) 188 | self.call_get_report(req, [], 1) 189 | 190 | def output_report(self, data, size, rtype): 191 | print('output', rtype, size, [f'{d:02x}' for d in data[:size]]) 192 | 193 | def process_one_event(self): 194 | buf = os.read(self._fd, 4380) 195 | assert len(buf) == 4380 196 | evtype = struct.unpack_from('< L', buf)[0] 197 | if evtype == UHIDDevice.UHID_START: 198 | ev, flags = struct.unpack_from('< L Q', buf) 199 | self.start(flags) 200 | elif evtype == UHIDDevice.UHID_OPEN: 201 | self.open() 202 | elif evtype == UHIDDevice.UHID_STOP: 203 | self.stop() 204 | elif evtype == UHIDDevice.UHID_CLOSE: 205 | self.close() 206 | elif evtype == UHIDDevice.UHID_SET_REPORT: 207 | ev, req, rnum, rtype, size, data = struct.unpack_from('< L L B B H 4096s', buf) 208 | self.set_report(req, rnum, rtype, size, data) 209 | elif evtype == UHIDDevice.UHID_GET_REPORT: 210 | ev, req, rnum, rtype = struct.unpack_from('< L L B B', buf) 211 | self.get_report(req, rnum, rtype) 212 | elif evtype == UHIDDevice.UHID_OUTPUT: 213 | ev, data, size, rtype = struct.unpack_from('< L 4096s H B', buf) 214 | self.output_report(data, size, rtype) 215 | -------------------------------------------------------------------------------- /tools/raw-log-converter.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright (c) 2019 Red Hat, Inc. 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation; either version 2 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | 20 | import argparse 21 | import os 22 | import sys 23 | from pathlib import Path 24 | import yaml 25 | import json 26 | import logging 27 | 28 | # This tool isn't installed, so we can assume that the tuhi module is always 29 | # in the parent directory 30 | sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)) + '/..') # noqa 31 | from tuhi.util import flatten # noqa 32 | from tuhi.drawing import Drawing # noqa 33 | from tuhi.protocol import StrokeFile # noqa 34 | from tuhi.export import JsonSvg, JsonPng # noqa 35 | from tuhi.wacom import WacomProtocolSpark, WacomProtocolIntuosPro, WacomProtocolSlate # noqa 36 | 37 | logging.basicConfig(format='%(asctime)s %(levelname)s: %(name)s: %(message)s', 38 | level=logging.INFO, 39 | datefmt='%H:%M:%S') 40 | logger = logging.getLogger('tuhi') # set the pseudo-root logger to take advantage of the other loggers 41 | 42 | 43 | def parse_file(filename, file_format, tablet_model, orientation): 44 | width = tablet_model.width 45 | height = tablet_model.height 46 | pressure = tablet_model.pressure 47 | point_size = tablet_model.point_size 48 | orientation = orientation or tablet_model.orientation 49 | 50 | stem = Path(filename).stem 51 | with open(filename) as fd: 52 | yml = yaml.load(fd, Loader=yaml.Loader) 53 | if not yml: 54 | print(f'{filename}: empty file.') 55 | return 56 | 57 | # all recv lists that have source PEN 58 | pendata = [d['recv'] for d in yml['data'] if 'recv' in d and 'source' in d and d['source'] == 'PEN'] 59 | data = list(flatten(pendata)) 60 | if not data: 61 | print(f'{filename}: no pen data.') 62 | return 63 | 64 | f = StrokeFile(data) 65 | # Spark doesn't have timestamps in the strokes, so use the file 66 | # timestamp itself 67 | timestamp = f.timestamp or yml['time'] 68 | # gotta convert to Drawings, then to json string, then to json, then 69 | # to svg. ffs. 70 | svgname = f'{stem}.svg' 71 | pngname = f'{stem}.png' 72 | jsonname = f'{stem}.json' 73 | d = Drawing(svgname, (width * point_size, height * point_size), timestamp) 74 | 75 | def normalize(p): 76 | NORMALIZED_RANGE = 0x10000 77 | return NORMALIZED_RANGE * p / pressure 78 | 79 | for s in f.strokes: 80 | stroke = d.new_stroke() 81 | for p in s.points: 82 | stroke.new_abs((p.x * point_size, p.y * point_size), normalize(p.p)) 83 | stroke.seal() 84 | d.seal() 85 | if file_format == 'json': 86 | with open(jsonname, 'w') as fd: 87 | fd.write(d.to_json()) 88 | return 89 | else: 90 | from io import StringIO 91 | js = json.load(StringIO(d.to_json())) 92 | if file_format == 'svg': 93 | JsonSvg(js, orientation, d.name) 94 | elif file_format == 'png': 95 | JsonPng(js, orientation, pngname) 96 | 97 | 98 | def fetch_files(): 99 | import xdg.BaseDirectory 100 | basedir = Path(xdg.BaseDirectory.xdg_data_home, 'tuhi') 101 | 102 | return [f for f in basedir.rglob('raw/*.yaml')] 103 | 104 | 105 | def main(args=sys.argv): 106 | long_description = ''' 107 | This tool is primarily a debugging tool but can be used to recover 108 | "lost" files. Use this tool if Tuhi failed to convert a drawing 109 | after downloading it from the device. Obviously after fixing the bug 110 | that failed to convert it. 111 | 112 | Input data is a raw log file. These are usually stored in 113 | \t$XDG_DATA_HOME/tuhi//raw/ 114 | 115 | Pass the log file to this tool and it will convert it to a JSON file or 116 | an SVG file. Alternatively, use --all to convert all 117 | all log files containing pen data in the above directory. 118 | 119 | Files are placed in $CWD and use file names containing the file time 120 | for easier identification. 121 | 122 | Copying the JSON files into the $XDG_DATA_HOME/tuhi/ will make them 123 | appear in the GUI. 124 | '''.replace(' ', '') 125 | 126 | parser = argparse.ArgumentParser(description='Converter tool from raw Tuhi log files to SVG and Tuhi JSON files.', 127 | formatter_class=argparse.RawDescriptionHelpFormatter, 128 | epilog=long_description) 129 | parser.add_argument('filename', help='The YAML file to load', nargs='?') 130 | parser.add_argument('--verbose', 131 | help='Show some debugging informations', 132 | action='store_true', 133 | default=False) 134 | parser.add_argument('--all', 135 | help='Convert all files in $XDG_DATA_DIR/tuhi/', 136 | action='store_true', 137 | default=False) 138 | parser.add_argument('--orientation', 139 | help='The orientation of the tablet. Default: the tablet model\'s default', 140 | default=None, 141 | choices=['landscape', 'portrait', 'reverse-landscape', 'reverse-portrait']) 142 | parser.add_argument('--tablet-model', 143 | help='Use defaults from the given tablet model', 144 | default='intuos-pro', 145 | choices=['intuos-pro', 'slate', 'spark']) 146 | parser.add_argument('--format', 147 | help='The format to generate. Default: svg', 148 | default='svg', 149 | choices=['svg', 'png', 'json']) 150 | 151 | ns = parser.parse_args(args[1:]) 152 | if ns.verbose: 153 | logger.setLevel(logging.DEBUG) 154 | 155 | if not ns.all: 156 | if ns.filename is None: 157 | print('filename is required, or use --all', file=sys.stderr) 158 | sys.exit(1) 159 | files = [ns.filename] 160 | else: 161 | files = fetch_files() 162 | 163 | model_map = { 164 | 'intuos-pro': WacomProtocolIntuosPro, 165 | 'slate': WacomProtocolSlate, 166 | 'spark': WacomProtocolSpark, 167 | } 168 | for f in files: 169 | parse_file(f, ns.format, model_map[ns.tablet_model], ns.orientation) 170 | 171 | 172 | if __name__ == '__main__': 173 | main() 174 | -------------------------------------------------------------------------------- /data/ui/Drawing.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | True 7 | False 8 | document-save-as-symbolic 9 | 10 | 11 | True 12 | False 13 | edit-delete-symbolic 14 | 15 | 16 | True 17 | False 18 | object-rotate-left-symbolic 19 | 20 | 21 | True 22 | False 23 | object-rotate-right-symbolic 24 | 25 | 151 | 152 | -------------------------------------------------------------------------------- /tuhi/gui/drawing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | from gettext import gettext as _ 15 | 16 | import xdg.BaseDirectory 17 | import os 18 | from pathlib import Path 19 | from .config import Config 20 | from tuhi.export import JsonSvg, JsonPng 21 | 22 | import gi 23 | gi.require_version("Gtk", "3.0") 24 | from gi.repository import GObject, Gtk, GdkPixbuf, Gdk # NOQA 25 | 26 | 27 | DATA_PATH = Path(xdg.BaseDirectory.xdg_cache_home, 'tuhi') 28 | SVG_DATA_PATH = Path(DATA_PATH, 'svg') 29 | PNG_DATA_PATH = Path(DATA_PATH, 'png') 30 | 31 | 32 | @Gtk.Template(resource_path='/org/freedesktop/Tuhi/ui/Drawing.ui') 33 | class Drawing(Gtk.EventBox): 34 | __gtype_name__ = "Drawing" 35 | 36 | box_toolbar = Gtk.Template.Child() 37 | image_svg = Gtk.Template.Child() 38 | btn_rotate_left = Gtk.Template.Child() 39 | btn_rotate_right = Gtk.Template.Child() 40 | 41 | def __init__(self, json_data, zoom, *args, **kwargs): 42 | super().__init__() 43 | self.orientation = Config().orientation 44 | Config().connect('notify::orientation', self._on_orientation_changed) 45 | SVG_DATA_PATH.mkdir(parents=True, exist_ok=True) 46 | PNG_DATA_PATH.mkdir(parents=True, exist_ok=True) 47 | 48 | self.json_data = json_data 49 | self._zoom = zoom 50 | self.process_svg() # sets self.svg 51 | self.redraw() 52 | 53 | self.timestamp = self.svg.timestamp 54 | self.box_toolbar.set_opacity(0) 55 | 56 | def _on_orientation_changed(self, config, pspec): 57 | self.orientation = config.orientation 58 | self.process_svg() 59 | self.redraw() 60 | 61 | def process_svg(self): 62 | path = os.fspath(Path(SVG_DATA_PATH, f'{self.json_data["timestamp"]}.svg')) 63 | self.svg = JsonSvg( 64 | self.json_data, 65 | self.orientation, 66 | path 67 | ) 68 | width, height = -1, -1 69 | if 'portrait' in self.orientation: 70 | height = 1000 71 | else: 72 | width = 1000 73 | self.pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename=self.svg.filename, 74 | width=width, 75 | height=height, 76 | preserve_aspect_ratio=True) 77 | 78 | def process_png(self): 79 | path = os.fspath(Path(PNG_DATA_PATH, f'{self.json_data["timestamp"]}.png')) 80 | self.png = JsonPng( 81 | self.json_data, 82 | self.orientation, 83 | path 84 | ) 85 | 86 | def redraw(self): 87 | ratio = self.pixbuf.get_height() / self.pixbuf.get_width() 88 | base = 250 + self.zoom * 50 89 | if 'portrait' in self.orientation: 90 | width = base / ratio 91 | height = base 92 | else: 93 | width = base 94 | height = base * ratio 95 | pb = self.pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) 96 | self.image_svg.set_from_pixbuf(pb) 97 | 98 | @GObject.Property 99 | def name(self): 100 | return "drawing" 101 | 102 | @GObject.Property 103 | def zoom(self): 104 | return self._zoom 105 | 106 | @zoom.setter 107 | def zoom(self, zoom): 108 | if zoom == self._zoom: 109 | return 110 | self._zoom = zoom 111 | self.redraw() 112 | 113 | @Gtk.Template.Callback('_on_download_button_clicked') 114 | def _on_download_button_clicked(self, button): 115 | dialog = Gtk.FileChooserNative() 116 | dialog.set_action(Gtk.FileChooserAction.SAVE) 117 | dialog.set_transient_for(self.get_toplevel()) 118 | 119 | dialog.set_do_overwrite_confirmation(True) 120 | # Translators: the default filename to save to 121 | dialog.set_current_name(_('untitled.svg')) 122 | 123 | filter_any = Gtk.FileFilter() 124 | # Translators: filter name to show all/any files 125 | filter_any.set_name(_('Any files')) 126 | filter_any.add_pattern('*') 127 | filter_svg = Gtk.FileFilter() 128 | # Translators: filter to show svg files only 129 | filter_svg.set_name(_('SVG files')) 130 | filter_svg.add_pattern('*.svg') 131 | filter_png = Gtk.FileFilter() 132 | # Translators: filter to show png files only 133 | filter_png.set_name(_('PNG files')) 134 | filter_png.add_pattern('*.png') 135 | dialog.add_filter(filter_svg) 136 | dialog.add_filter(filter_png) 137 | dialog.add_filter(filter_any) 138 | 139 | response = dialog.run() 140 | if response == Gtk.ResponseType.ACCEPT: 141 | import shutil 142 | 143 | file = dialog.get_filename() 144 | 145 | if file.lower().endswith('.png'): 146 | # regenerate the PNG based on the current rotation. 147 | # where we used the orientation buttons, we haven't updated the 148 | # file itself. 149 | self.process_png() 150 | shutil.move(self.png.filename, file) 151 | else: 152 | # regenerate the SVG based on the current rotation. 153 | # where we used the orientation buttons, we haven't updated the 154 | # file itself. 155 | self.process_svg() 156 | shutil.copyfile(self.svg.filename, file) 157 | # FIXME: error handling 158 | 159 | dialog.destroy() 160 | 161 | @Gtk.Template.Callback('_on_delete_button_clicked') 162 | def _on_delete_button_clicked(self, button): 163 | Config().delete_drawing(self.timestamp) 164 | 165 | @Gtk.Template.Callback('_on_rotate_button_clicked') 166 | def _on_rotate_button_clicked(self, button): 167 | if button == self.btn_rotate_left: 168 | self.pixbuf = self.pixbuf.rotate_simple(GdkPixbuf.PixbufRotation.COUNTERCLOCKWISE) 169 | advance = 1 170 | else: 171 | self.pixbuf = self.pixbuf.rotate_simple(GdkPixbuf.PixbufRotation.CLOCKWISE) 172 | advance = 3 173 | 174 | orientations = ['portrait', 'landscape', 'reverse-portrait', 'reverse-landscape'] * 3 175 | o = orientations[orientations.index(self.orientation) + advance] 176 | self.orientation = o 177 | self.redraw() 178 | 179 | @Gtk.Template.Callback('_on_enter') 180 | def _on_enter(self, *args): 181 | self.box_toolbar.set_opacity(100) 182 | 183 | @Gtk.Template.Callback('_on_leave') 184 | def _on_leave(self, drawing, event): 185 | if event.detail == Gdk.NotifyType.INFERIOR: 186 | return 187 | self.box_toolbar.set_opacity(0) 188 | -------------------------------------------------------------------------------- /data/ui/DrawingPerspective.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 147 | 148 | -------------------------------------------------------------------------------- /tuhi/gui/drawingperspective.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | from .drawing import Drawing 15 | from .config import Config 16 | 17 | import time 18 | import logging 19 | 20 | import gi 21 | gi.require_version("Gtk", "3.0") 22 | from gi.repository import GObject, Gtk # NOQA 23 | 24 | logger = logging.getLogger('tuhi.gui.drawingperspective') 25 | 26 | 27 | @Gtk.Template(resource_path="/org/freedesktop/Tuhi/ui/Flowbox.ui") 28 | class Flowbox(Gtk.Box): 29 | __gtype_name__ = "Flowbox" 30 | 31 | label_date = Gtk.Template.Child() 32 | flowbox_drawings = Gtk.Template.Child() 33 | 34 | def __init__(self, timestruct, *args, **kwargs): 35 | super().__init__(*args, **kwargs) 36 | self.time = timestruct 37 | self.label_date.set_text(time.strftime('%B %Y', self.time)) 38 | 39 | def insert(self, drawing): 40 | # We don't know which order we get drawings from the device, so 41 | # let's do a sorted insert here 42 | index = 0 43 | child = self.flowbox_drawings.get_child_at_index(index) 44 | while child is not None: 45 | if child.get_child().timestamp < drawing.timestamp: 46 | break 47 | index += 1 48 | child = self.flowbox_drawings.get_child_at_index(index) 49 | 50 | self.flowbox_drawings.insert(drawing, index) 51 | 52 | def delete(self, drawing): 53 | def delete_matching_child(child, drawing): 54 | if child.get_child() == drawing: 55 | self.flowbox_drawings.remove(child) 56 | self.flowbox_drawings.foreach(delete_matching_child, drawing) 57 | 58 | @GObject.Property 59 | def is_empty(self): 60 | return not self.flowbox_drawings.get_children() 61 | 62 | 63 | @Gtk.Template(resource_path="/org/freedesktop/Tuhi/ui/DrawingPerspective.ui") 64 | class DrawingPerspective(Gtk.Stack): 65 | __gtype_name__ = "DrawingPerspective" 66 | 67 | viewport = Gtk.Template.Child() 68 | overlay_undo = Gtk.Template.Child() 69 | notification_delete_undo = Gtk.Template.Child() 70 | notification_delete_close = Gtk.Template.Child() 71 | box_all_drawings = Gtk.Template.Child() 72 | 73 | def __init__(self, *args, **kwargs): 74 | super().__init__(*args, **kwargs) 75 | self.known_drawings = {} # type {timestamp: Drawing()} 76 | self.flowboxes = {} 77 | # Add an expanding emtpy label to the bottom - this pushes all the 78 | # real stuff up to the top, forcing a nice alignment 79 | fake_label = Gtk.Label("") 80 | fake_label.show() 81 | self.box_all_drawings.pack_end(fake_label, expand=True, fill=True, padding=100) 82 | self._zoom = 0 83 | self._want_listen = True 84 | 85 | def _cache_drawings(self, device, pspec): 86 | # The config backend filters duplicates anyway, so don't care here 87 | for ts in self.device.drawings_available: 88 | json_string = self.device.json(ts) 89 | Config().add_drawing(ts, json_string) 90 | 91 | def _update_drawings(self, config, pspec): 92 | def _hash(drawing): 93 | return time.strftime('%Y%m', time.gmtime(drawing.timestamp)) 94 | 95 | for js in sorted(config.drawings, key=lambda j: j['timestamp']): 96 | ts = js['timestamp'] 97 | if ts in self.known_drawings: 98 | continue 99 | 100 | drawing = Drawing(js, self._zoom) 101 | self.known_drawings[ts] = drawing 102 | 103 | # Now pick the right monthly flowbox to insert into 104 | key = _hash(drawing) 105 | try: 106 | fb = self.flowboxes[key] 107 | except KeyError: 108 | fb = Flowbox(time.gmtime(drawing.timestamp)) 109 | self.flowboxes[key] = fb 110 | self.box_all_drawings.pack_end(fb, expand=False, fill=True, padding=0) 111 | finally: 112 | fb.insert(drawing) 113 | 114 | # Remove deleted drawings 115 | deleted = [ts for ts in self.known_drawings if ts not in [js['timestamp'] for js in config.drawings]] 116 | for ts in deleted: 117 | drawing = self.known_drawings[ts] 118 | fb = self.flowboxes[_hash(drawing)] 119 | fb.delete(drawing) 120 | if fb.is_empty: 121 | del self.flowboxes[_hash(drawing)] 122 | self.box_all_drawings.remove(fb) 123 | del self.known_drawings[ts] 124 | self.notification_delete_undo.deleted_drawing = drawing.timestamp 125 | self.overlay_undo.set_reveal_child(True) 126 | 127 | @GObject.Property 128 | def device(self): 129 | return self._device 130 | 131 | @device.setter 132 | def device(self, device): 133 | self._device = device 134 | 135 | self._signals = [] 136 | sig = device.connect('notify::connected', self._on_connected) 137 | self._signals.append(sig) 138 | sig = device.connect('notify::listening', self._on_listening_stopped) 139 | self._signals.append(sig) 140 | sig = device.connect('device-error', self._on_device_error) 141 | self._signals.append(sig) 142 | 143 | # This is a bit convoluted. We need to cache all drawings 144 | # because Tuhi doesn't have guaranteed storage. So any json that 145 | # comes in from Tuhi, we pass to our config backend to save 146 | # somewhere. 147 | # The config backend adds the json file and emits a notify for the 148 | # json itself (once cached) that we then actually use for SVG 149 | # generation. 150 | device.connect('notify::drawings-available', self._cache_drawings) 151 | Config().connect('notify::drawings', self._update_drawings) 152 | 153 | self._update_drawings(Config(), None) 154 | 155 | # We always want to sync on startup 156 | logger.debug(f'{device.name} - starting to listen') 157 | device.start_listening() 158 | 159 | @GObject.Property 160 | def name(self): 161 | return "drawing_perspective" 162 | 163 | @GObject.Property 164 | def zoom(self): 165 | return self._zoom 166 | 167 | @zoom.setter 168 | def zoom(self, zoom): 169 | if zoom == self._zoom: 170 | return 171 | 172 | self._zoom = zoom 173 | for ts, drawing in self.known_drawings.items(): 174 | drawing.zoom = zoom 175 | 176 | def _on_connected(self, device, pspec): 177 | # Turns out we don't really care about whether the device is 178 | # connected or not, it has little effect on how we work here 179 | pass 180 | 181 | def _on_listening_stopped(self, device, pspec): 182 | if not device.listening and self._want_listen: 183 | logger.debug(f'{device.name} - listening stopped, restarting') 184 | # We never want to stop listening 185 | device.start_listening() 186 | 187 | def _on_device_error(self, device, error): 188 | import errno 189 | if error == -errno.EACCES: 190 | # No point to keep getting notified 191 | for sig in self._signals: 192 | device.disconnect(sig) 193 | self._signals = [] 194 | self._want_listen = False 195 | 196 | @Gtk.Template.Callback('_on_undo_close_clicked') 197 | def _on_undo_close_clicked(self, button): 198 | self.overlay_undo.set_reveal_child(False) 199 | 200 | @Gtk.Template.Callback('_on_undo_clicked') 201 | def _on_undo_clicked(self, button): 202 | Config().undelete_drawing(button.deleted_drawing) 203 | self.overlay_undo.set_reveal_child(False) 204 | -------------------------------------------------------------------------------- /tools/tuhi-live.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | import argparse 15 | import logging 16 | import os 17 | import pwd 18 | import sys 19 | import multiprocessing 20 | from multiprocessing import reduction 21 | 22 | try: 23 | import tuhi.dbusclient 24 | except ModuleNotFoundError: 25 | # If PYTHONPATH isn't set up or we never installed Tuhi, the module 26 | # isn't available. And since we don't install tuhi-live, we can assume that 27 | # we're still in the git repo, so messing with the path is "fine". 28 | sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)) + '/..') # noqa 29 | import tuhi.dbusclient 30 | 31 | 32 | manager = None 33 | logger = None 34 | 35 | 36 | def open_uhid_process(queue_in, conn_out): 37 | while True: 38 | try: 39 | pid = queue_in.get() 40 | except KeyboardInterrupt: 41 | return 0 42 | else: 43 | fd = os.open('/dev/uhid', os.O_RDWR) 44 | reduction.send_handle(conn_out, fd, pid) 45 | 46 | 47 | def maybe_start_tuhi(queue): 48 | try: 49 | should_start, args = queue.get() 50 | except KeyboardInterrupt: 51 | return 0 52 | 53 | if not should_start: 54 | return 55 | 56 | sys.path.append(os.getcwd()) 57 | 58 | import tuhi.base 59 | import signal 60 | 61 | # we don't want to kill Tuhi on ctrl+c because we won't be able to reset 62 | # live mode. Instead we rely on tuhi-live to take us down when it exits 63 | signal.signal(signal.SIGINT, signal.SIG_IGN) 64 | 65 | args = ['tuhi-live'] + args # argparse in tuhi.base.main skips argv[0] 66 | 67 | tuhi.base.main(args) 68 | 69 | 70 | def start_tuhi_server(args): 71 | queue = multiprocessing.Queue() 72 | 73 | tuhi_process = multiprocessing.Process(target=maybe_start_tuhi, args=(queue,)) 74 | tuhi_process.daemon = True 75 | tuhi_process.start() 76 | 77 | sys.path.append(os.path.join(os.getcwd(), 'tools')) 78 | 79 | # import after spawning the process, or the 2 processes will fight for GLib 80 | import kete 81 | from gi.repository import Gio, GLib 82 | 83 | global logger 84 | logger = logging.getLogger('tuhi-live') 85 | logger.addHandler(kete.logger_handler) 86 | logger.setLevel(logging.INFO) 87 | 88 | logger.debug('connecting to the bus') 89 | 90 | # connect to the session 91 | try: 92 | connection = Gio.bus_get_sync(Gio.BusType.SESSION, None) 93 | except GLib.Error as e: 94 | if (e.domain == 'g-io-error-quark' and 95 | e.code == Gio.IOErrorEnum.DBUS_ERROR): 96 | raise tuhi.dbusclient.DBusError(e.message) 97 | else: 98 | raise e 99 | 100 | logger.debug('looking for tuhi on the bus') 101 | # attempt to connect to tuhi 102 | try: 103 | proxy = Gio.DBusProxy.new_sync(connection, 104 | Gio.DBusProxyFlags.NONE, None, 105 | tuhi.dbusclient.TUHI_DBUS_NAME, 106 | tuhi.dbusclient.ROOT_PATH, 107 | tuhi.dbusclient.ORG_FREEDESKTOP_TUHI1_MANAGER, 108 | None) 109 | except GLib.Error as e: 110 | if (e.domain == 'g-io-error-quark' and 111 | e.code == Gio.IOErrorEnum.DBUS_ERROR): 112 | raise tuhi.dbusclient.DBusError(e.message) 113 | else: 114 | raise e 115 | 116 | started = proxy.get_name_owner() is not None 117 | 118 | if not started: 119 | print(f'No-one is handling {tuhi.dbusclient.TUHI_DBUS_NAME}, attempting to start a daemon') 120 | 121 | queue.put((not started, args)) 122 | 123 | 124 | def run_live(request_fd_queue, conn_fd): 125 | from gi.repository import Gio, GLib 126 | 127 | def on_name_appeared(connection, name, client): 128 | global manager 129 | logger.info('Connected to the Tuhi daemon') 130 | manager = tuhi.dbusclient.TuhiDBusClientManager() 131 | 132 | for device in manager.devices: 133 | if device.live: 134 | logger.info(f'{device} is already live, stopping first') 135 | device.stop_live() 136 | logger.info(f'starting live on {device}, please press button on the device') 137 | request_fd_queue.put(os.getpid()) 138 | fd = reduction.recv_handle(conn_fd) 139 | device.start_live(fd) 140 | 141 | Gio.bus_watch_name(Gio.BusType.SESSION, 142 | tuhi.dbusclient.TUHI_DBUS_NAME, 143 | Gio.BusNameWatcherFlags.NONE, 144 | on_name_appeared, 145 | None) 146 | 147 | mainloop = GLib.MainLoop() 148 | 149 | def on_disconnect(dev, pspec): 150 | mainloop.quit() 151 | 152 | wait_for_disconnect = False 153 | 154 | try: 155 | mainloop.run() 156 | except KeyboardInterrupt: 157 | pass 158 | finally: 159 | for device in manager.devices: 160 | if device.live and device.connected: 161 | logger.info(f'stopping live on {device}') 162 | device.connect('notify::connected', on_disconnect) 163 | device.stop_live() 164 | wait_for_disconnect = True 165 | 166 | # we re-run the mainloop to terminate the connections 167 | if wait_for_disconnect: 168 | try: 169 | mainloop.run() 170 | except KeyboardInterrupt: 171 | pass 172 | 173 | 174 | def drop_privileges(): 175 | sys.stderr.write('dropping privileges\n') 176 | 177 | os.setgroups([]) 178 | gid = int(os.getenv('SUDO_GID')) 179 | uid = int(os.getenv('SUDO_UID')) 180 | pwname = os.getenv('SUDO_USER') 181 | os.setresgid(gid, gid, gid) 182 | os.initgroups(pwname, gid) 183 | os.setresuid(uid, uid, uid) 184 | 185 | pw = pwd.getpwuid(uid) 186 | path = os.environ['PATH'] 187 | display = os.environ['DISPLAY'] 188 | 189 | # we completely clear the environment and start a new and controlled one 190 | os.environ.clear() 191 | os.environ['XDG_RUNTIME_DIR'] = f'/run/user/{uid}' 192 | os.environ['HOME'] = pw.pw_dir 193 | os.environ['PATH'] = path 194 | os.environ['DISPLAY'] = display 195 | 196 | 197 | def parse(args): 198 | parser = argparse.ArgumentParser(description='Tool to start live mode') 199 | parser.add_argument('--flatpak-compatibility-mode', 200 | help='Use the flatpak xdg directories', 201 | action='store_true', 202 | default=False) 203 | 204 | ns, remaining_args = parser.parse_known_args(args[1:]) 205 | return ns, remaining_args 206 | 207 | 208 | def main(args=sys.argv): 209 | if not os.geteuid() == 0: 210 | sys.exit('Script must be run as root') 211 | 212 | our_args, remaining_args = parse(args) 213 | request_fd_queue = multiprocessing.Queue() 214 | conn_in, conn_out = multiprocessing.Pipe() 215 | 216 | fd_process = multiprocessing.Process(target=open_uhid_process, args=(request_fd_queue, conn_out)) 217 | fd_process.daemon = True 218 | fd_process.start() 219 | 220 | drop_privileges() 221 | 222 | if our_args.flatpak_compatibility_mode: 223 | from pathlib import Path 224 | 225 | # tuhi-live is usually started through sudo, so let's get to the 226 | # user's home directory here. 227 | userhome = Path(os.path.expanduser('~' + os.getlogin())) 228 | basedir = userhome / '.var' / 'app' / 'org.freedesktop.Tuhi' 229 | print(f'Using flatpak xdg dirs in {basedir}') 230 | os.environ['XDG_DATA_HOME'] = os.fspath(basedir / 'data') 231 | os.environ['XDG_CONFIG_HOME'] = os.fspath(basedir / 'config') 232 | os.environ['XDG_CACHE_HOME'] = os.fspath(basedir / 'cache') 233 | 234 | start_tuhi_server(remaining_args) 235 | run_live(request_fd_queue, conn_in) 236 | 237 | 238 | if __name__ == '__main__': 239 | main(sys.argv) 240 | -------------------------------------------------------------------------------- /tuhi/gui/window.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | from .drawingperspective import DrawingPerspective 15 | from .config import Config 16 | from tuhi.dbusclient import TuhiDBusClientManager 17 | 18 | from gettext import gettext as _ 19 | import logging 20 | 21 | import gi 22 | gi.require_version("Gtk", "3.0") 23 | from gi.repository import Gtk, Gio, GLib, GObject # NOQA 24 | 25 | logger = logging.getLogger('tuhi.gui.window') 26 | 27 | 28 | @Gtk.Template(resource_path="/org/freedesktop/Tuhi/ui/ErrorPerspective.ui") 29 | class ErrorPerspective(Gtk.Box): 30 | ''' 31 | The page loaded when we cannot connect to the Tuhi DBus server. 32 | ''' 33 | __gtype_name__ = "ErrorPerspective" 34 | 35 | def __init__(self, *args, **kwargs): 36 | super().__init__(*args, **kwargs) 37 | 38 | @GObject.Property 39 | def name(self): 40 | return "error_perspective" 41 | 42 | 43 | @Gtk.Template(resource_path="/org/freedesktop/Tuhi/ui/SetupPerspective.ui") 44 | class SetupDialog(Gtk.Dialog): 45 | ''' 46 | The setup dialog when we don't yet have a registered device with Tuhi. 47 | ''' 48 | __gtype_name__ = "SetupDialog" 49 | __gsignals__ = { 50 | 'new-device': 51 | (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), 52 | } 53 | 54 | stack = Gtk.Template.Child() 55 | label_devicename_p1 = Gtk.Template.Child() 56 | btn_quit = Gtk.Template.Child() 57 | 58 | def __init__(self, tuhi, *args, **kwargs): 59 | super().__init__(*args, **kwargs) 60 | self._tuhi = tuhi 61 | self._sig = tuhi.connect('unregistered-device', self._on_unregistered_device) 62 | tuhi.start_search() 63 | self.device = None 64 | 65 | def _on_unregistered_device(self, tuhi, device): 66 | tuhi.disconnect(self._sig) 67 | 68 | self.label_devicename_p1.set_text(_(f'Connecting to {device.name}')) 69 | self.stack.set_visible_child_name('page1') 70 | self._sig = device.connect('button-press-required', self._on_button_press_required) 71 | device.register() 72 | 73 | def _on_button_press_required(self, device): 74 | device.disconnect(self._sig) 75 | 76 | self.stack.set_visible_child_name('page2') 77 | self._sig = device.connect('registered', self._on_registered) 78 | 79 | def _on_registered(self, device): 80 | device.disconnect(self._sig) 81 | self.device = device 82 | self.response(Gtk.ResponseType.OK) 83 | 84 | @GObject.Property 85 | def name(self): 86 | return "setup_dialog" 87 | 88 | 89 | @Gtk.Template(resource_path='/org/freedesktop/Tuhi/ui/MainWindow.ui') 90 | class MainWindow(Gtk.ApplicationWindow): 91 | __gtype_name__ = 'MainWindow' 92 | 93 | stack_perspectives = Gtk.Template.Child() 94 | headerbar = Gtk.Template.Child() 95 | menubutton1 = Gtk.Template.Child() 96 | spinner_sync = Gtk.Template.Child() 97 | image_battery = Gtk.Template.Child() 98 | image_missing_tablet = Gtk.Template.Child() 99 | overlay_reauth = Gtk.Template.Child() 100 | 101 | def __init__(self, **kwargs): 102 | super().__init__(**kwargs) 103 | 104 | self.maximize() 105 | self._tuhi = TuhiDBusClientManager() 106 | 107 | action = Gio.SimpleAction.new_stateful('orientation', GLib.VariantType('s'), 108 | GLib.Variant('s', 'landscape')) 109 | action.connect('activate', self._on_orientation_changed) 110 | action.set_state(GLib.Variant.new_string(Config().orientation)) 111 | self.add_action(action) 112 | 113 | builder = Gtk.Builder.new_from_resource('/org/freedesktop/Tuhi/ui/AppMenu.ui') 114 | menu = builder.get_object("primary-menu") 115 | self.menubutton1.set_menu_model(menu) 116 | 117 | ep = ErrorPerspective() 118 | self._add_perspective(ep) 119 | self.stack_perspectives.set_visible_child_name(ep.name) 120 | 121 | self._signals = [] 122 | 123 | # the dbus bindings need more async... 124 | if not self._tuhi.online: 125 | self._tuhi.connect('notify::online', self._on_dbus_online) 126 | else: 127 | self._on_dbus_online() 128 | 129 | def _register_device(self): 130 | dialog = SetupDialog(self._tuhi) 131 | dialog.set_transient_for(self) 132 | dialog.connect('response', self._on_setup_dialog_closed) 133 | dialog.show() 134 | 135 | def _on_dbus_online(self, *args, **kwargs): 136 | logger.debug('dbus is online') 137 | 138 | dp = DrawingPerspective() 139 | self._add_perspective(dp) 140 | self.headerbar.set_title('Tuhi') 141 | self.stack_perspectives.set_visible_child_name(dp.name) 142 | 143 | if not self._tuhi.devices: 144 | self._register_device() 145 | else: 146 | device = self._tuhi.devices[0] 147 | self._init_device(device) 148 | dp.device = device 149 | self.headerbar.set_title(f'Tuhi - {dp.device.name}') 150 | 151 | def _init_device(self, device): 152 | sig = device.connect('notify::sync-state', self._on_sync_state) 153 | self._signals.append(sig) 154 | sig = device.connect('notify::battery-percent', self._on_battery_changed) 155 | self._signals.append(sig) 156 | sig = device.connect('notify::battery-state', self._on_battery_changed) 157 | self._signals.append(sig) 158 | sig = device.connect('device-error', self._on_device_error) 159 | self._signals.append(sig) 160 | self._on_battery_changed(device, None) 161 | 162 | def _on_battery_changed(self, device, pspec): 163 | if device.battery_percent > 80: 164 | fill = 'full' 165 | elif device.battery_percent > 40: 166 | fill = 'good' 167 | elif device.battery_percent > 10: 168 | fill = 'low' 169 | else: 170 | fill = 'caution' 171 | 172 | if device.battery_state == 1: 173 | state = '-charging' 174 | elif device.battery_state == 0: # unknown 175 | fill = 'missing' 176 | state = '' 177 | else: 178 | state = '' 179 | batt_icon_name = f'battery-{fill}{state}-symbolic' 180 | _, isize = self.image_battery.get_icon_name() 181 | self.image_battery.set_from_icon_name(batt_icon_name, isize) 182 | self.image_battery.set_tooltip_text(f'{device.battery_percent}%') 183 | 184 | def _on_sync_state(self, device, pspec): 185 | self.image_missing_tablet.set_visible(False) 186 | if device.sync_state: 187 | self.spinner_sync.start() 188 | else: 189 | self.spinner_sync.stop() 190 | 191 | def _on_setup_dialog_closed(self, dialog, response): 192 | self.overlay_reauth.set_reveal_child(False) 193 | device = dialog.device 194 | dialog.destroy() 195 | 196 | if response != Gtk.ResponseType.OK or device is None: 197 | self.destroy() 198 | return 199 | 200 | logger.debug('device was registered') 201 | self.headerbar.set_title(f'Tuhi - {device.name}') 202 | 203 | dp = self._get_child('drawing_perspective') 204 | dp.device = device 205 | self._init_device(device) 206 | self.stack_perspectives.set_visible_child_name(dp.name) 207 | 208 | def _on_device_error(self, device, err): 209 | import errno 210 | logger.info(f'Device error: {err}') 211 | if err == -errno.EACCES: 212 | self.overlay_reauth.set_reveal_child(True) 213 | # No point to keep getting notified, it won't be able to 214 | # register. 215 | for sig in self._signals: 216 | device.disconnect(sig) 217 | self._signals = [] 218 | 219 | def _add_perspective(self, perspective): 220 | self.stack_perspectives.add_named(perspective, perspective.name) 221 | 222 | def _get_child(self, name): 223 | return self.stack_perspectives.get_child_by_name(name) 224 | 225 | def _on_reconnect_tuhi(self, tuhi): 226 | self._tuhi = tuhi 227 | 228 | def _on_orientation_changed(self, action, label): 229 | action.set_state(label) 230 | Config().orientation = label.get_string() # this is a GVariant 231 | 232 | @Gtk.Template.Callback('_on_zoom_changed') 233 | def _on_zoom_changed(self, adjustment): 234 | dp = self._get_child('drawing_perspective') 235 | dp.zoom = int(adjustment.get_value()) 236 | 237 | @Gtk.Template.Callback('_on_reauth_clicked') 238 | def _on_reauth_clicked(self, button): 239 | self._register_device() 240 | -------------------------------------------------------------------------------- /data/ui/MainWindow.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 7 | 1.0000000002235174 8 | 1 9 | 10 | 11 | 206 | 207 | -------------------------------------------------------------------------------- /data/org.freedesktop.Tuhi.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/ui/SetupPerspective.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 278 | 279 | -------------------------------------------------------------------------------- /tuhi/dbusclient.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | 14 | from gi.repository import GObject, Gio, GLib 15 | import argparse 16 | import errno 17 | import os 18 | import logging 19 | import re 20 | 21 | logger = logging.getLogger('tuhi.dbusclient') 22 | 23 | TUHI_DBUS_NAME = 'org.freedesktop.tuhi1' 24 | ORG_FREEDESKTOP_TUHI1_MANAGER = 'org.freedesktop.tuhi1.Manager' 25 | ORG_FREEDESKTOP_TUHI1_DEVICE = 'org.freedesktop.tuhi1.Device' 26 | ROOT_PATH = '/org/freedesktop/tuhi1' 27 | 28 | ORG_BLUEZ_DEVICE1 = 'org.bluez.Device1' 29 | 30 | 31 | class DBusError(Exception): 32 | def __init__(self, message): 33 | self.message = message 34 | 35 | 36 | class _DBusObject(GObject.Object): 37 | _connection = None 38 | 39 | def __init__(self, name, interface, objpath): 40 | super().__init__() 41 | 42 | # this is not handled asynchronously because if we fail to 43 | # get the session bus, we have other issues 44 | if _DBusObject._connection is None: 45 | self._connect_to_session() 46 | 47 | self.interface = interface 48 | self.objpath = objpath 49 | self._online = False 50 | self._name = name 51 | try: 52 | self._connect() 53 | except DBusError: 54 | self._reconnect_timer = GObject.timeout_add_seconds(2, self._on_reconnect_timer) 55 | 56 | def _connect(self): 57 | try: 58 | self.proxy = Gio.DBusProxy.new_sync(self._connection, 59 | Gio.DBusProxyFlags.NONE, None, 60 | self._name, self.objpath, 61 | self.interface, None) 62 | if self.proxy.get_name_owner() is None: 63 | raise DBusError(f'No-one is handling {self._name}, is the daemon running?') 64 | 65 | self._online = True 66 | self.notify('online') 67 | except GLib.Error as e: 68 | if (e.domain == 'g-io-error-quark' and 69 | e.code == Gio.IOErrorEnum.DBUS_ERROR): 70 | raise DBusError(e.message) 71 | else: 72 | raise e 73 | 74 | self.proxy.connect('g-properties-changed', self._on_properties_changed) 75 | self.proxy.connect('g-signal', self._on_signal_received) 76 | 77 | def _on_reconnect_timer(self): 78 | try: 79 | logger.debug('reconnecting') 80 | self._connect() 81 | return False 82 | except DBusError: 83 | return True 84 | 85 | @GObject.Property 86 | def online(self): 87 | return self._online 88 | 89 | def _connect_to_session(self): 90 | try: 91 | _DBusObject._connection = Gio.bus_get_sync(Gio.BusType.SESSION, None) 92 | except GLib.Error as e: 93 | if (e.domain == 'g-io-error-quark' and 94 | e.code == Gio.IOErrorEnum.DBUS_ERROR): 95 | raise DBusError(e.message) 96 | else: 97 | raise e 98 | 99 | def _on_properties_changed(self, proxy, changed_props, invalidated_props): 100 | # Implement this in derived classes to respond to property changes 101 | pass 102 | 103 | def _on_signal_received(self, proxy, sender, signal, parameters): 104 | # Implement this in derived classes to respond to signals 105 | pass 106 | 107 | def property(self, name): 108 | p = self.proxy.get_cached_property(name) 109 | if p is not None: 110 | return p.unpack() 111 | return p 112 | 113 | def terminate(self): 114 | del self.proxy 115 | 116 | 117 | class _DBusSystemObject(_DBusObject): 118 | ''' 119 | Same as the _DBusObject, but connects to the system bus instead 120 | ''' 121 | def __init__(self, name, interface, objpath): 122 | self._connect_to_system() 123 | super().__init__(name, interface, objpath) 124 | 125 | def _connect_to_system(self): 126 | try: 127 | self._connection = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) 128 | except GLib.Error as e: 129 | if (e.domain == 'g-io-error-quark' and 130 | e.code == Gio.IOErrorEnum.DBUS_ERROR): 131 | raise DBusError(e.message) 132 | else: 133 | raise e 134 | 135 | 136 | class BlueZDevice(_DBusSystemObject): 137 | def __init__(self, objpath): 138 | super().__init__('org.bluez', ORG_BLUEZ_DEVICE1, objpath) 139 | self.proxy.connect('g-properties-changed', self._on_properties_changed) 140 | 141 | @GObject.Property 142 | def connected(self): 143 | return self.proxy.get_cached_property('Connected').unpack() 144 | 145 | def _on_properties_changed(self, obj, properties, invalidated_properties): 146 | properties = properties.unpack() 147 | 148 | if 'Connected' in properties: 149 | self.notify('connected') 150 | 151 | 152 | class TuhiDBusClientDevice(_DBusObject): 153 | __gsignals__ = { 154 | 'button-press-required': 155 | (GObject.SignalFlags.RUN_FIRST, None, ()), 156 | 'registered': 157 | (GObject.SignalFlags.RUN_FIRST, None, ()), 158 | 'device-error': 159 | (GObject.SignalFlags.RUN_FIRST, None, (int,)), 160 | } 161 | 162 | def __init__(self, manager, objpath): 163 | super().__init__(TUHI_DBUS_NAME, ORG_FREEDESKTOP_TUHI1_DEVICE, objpath) 164 | self.manager = manager 165 | self.is_registering = False 166 | self._bluez_device = BlueZDevice(self.property('BlueZDevice')) 167 | self._bluez_device.connect('notify::connected', self._on_connected) 168 | self._sync_state = 0 169 | 170 | @classmethod 171 | def is_device_address(cls, string): 172 | if re.match(r'[0-9a-f]{2}(:[0-9a-f]{2}){5}$', string.lower()): 173 | return string 174 | raise argparse.ArgumentTypeError(f'"{string}" is not a valid device address') 175 | 176 | @GObject.Property 177 | def address(self): 178 | return self._bluez_device.property('Address') 179 | 180 | @GObject.Property 181 | def name(self): 182 | return self._bluez_device.property('Name') 183 | 184 | @GObject.Property 185 | def dimensions(self): 186 | return self.property('Dimensions') 187 | 188 | @GObject.Property 189 | def listening(self): 190 | return self.property('Listening') 191 | 192 | @GObject.Property 193 | def drawings_available(self): 194 | return self.property('DrawingsAvailable') 195 | 196 | @GObject.Property 197 | def battery_percent(self): 198 | return self.property('BatteryPercent') 199 | 200 | @GObject.Property 201 | def battery_state(self): 202 | return self.property('BatteryState') 203 | 204 | @GObject.Property 205 | def connected(self): 206 | return self._bluez_device.connected 207 | 208 | @GObject.Property 209 | def sync_state(self): 210 | return self._sync_state 211 | 212 | @GObject.Property 213 | def live(self): 214 | return self.property('Live') 215 | 216 | def _on_connected(self, bluez_device, pspec): 217 | self.notify('connected') 218 | 219 | def register(self): 220 | logger.debug(f'{self}: Register') 221 | # FIXME: Register() doesn't return anything useful yet, so we wait until 222 | # the device is in the Manager's Devices property 223 | self.s1 = self.manager.connect('notify::devices', self._on_mgr_devices_updated) 224 | self.is_registering = True 225 | self.proxy.Register() 226 | 227 | def start_listening(self): 228 | self.proxy.StartListening() 229 | 230 | def stop_listening(self): 231 | try: 232 | self.proxy.StopListening() 233 | except GLib.Error as e: 234 | if (e.domain != 'g-dbus-error-quark' or 235 | e.code != Gio.IOErrorEnum.EXISTS or 236 | Gio.dbus_error_get_remote_error(e) != 'org.freedesktop.DBus.Error.ServiceUnknown'): 237 | raise e 238 | 239 | def json(self, timestamp): 240 | SUPPORTED_FILE_FORMAT = 1 241 | return self.proxy.GetJSONData('(ut)', SUPPORTED_FILE_FORMAT, timestamp) 242 | 243 | def _on_signal_received(self, proxy, sender, signal, parameters): 244 | if signal == 'ButtonPressRequired': 245 | logger.info(f'{self}: Press button on device now') 246 | self.emit('button-press-required') 247 | elif signal == 'ListeningStopped': 248 | err = parameters[0] 249 | if err == -errno.EACCES: 250 | logger.error(f'{self}: wrong device, please re-register.') 251 | elif err < 0: 252 | logger.error(f'{self}: an error occured: {os.strerror(-err)}') 253 | self.emit('device-error', err) 254 | self.notify('listening') 255 | elif signal == 'SyncState': 256 | self._sync_state = parameters[0] 257 | self.notify('sync-state') 258 | 259 | def _on_properties_changed(self, proxy, changed_props, invalidated_props): 260 | if changed_props is None: 261 | return 262 | 263 | changed_props = changed_props.unpack() 264 | 265 | if 'DrawingsAvailable' in changed_props: 266 | self.notify('drawings-available') 267 | elif 'Listening' in changed_props: 268 | self.notify('listening') 269 | elif 'BatteryPercent' in changed_props: 270 | self.notify('battery-percent') 271 | elif 'BatteryState' in changed_props: 272 | self.notify('battery-state') 273 | elif 'Live' in changed_props: 274 | self.notify('live') 275 | 276 | def __repr__(self): 277 | return f'{self.address} - {self.name}' 278 | 279 | def _on_mgr_devices_updated(self, manager, pspec): 280 | if not self.is_registering: 281 | return 282 | 283 | for d in manager.devices: 284 | if d.address == self.address: 285 | self.is_registering = False 286 | self.manager.disconnect(self.s1) 287 | del self.s1 288 | logger.info(f'{self}: Registration successful') 289 | self.emit('registered') 290 | 291 | def start_live(self, fd): 292 | fd_list = Gio.UnixFDList.new() 293 | fd_list.append(fd) 294 | 295 | res, fds = self.proxy.call_with_unix_fd_list_sync('org.freedesktop.tuhi1.Device.StartLive', 296 | GLib.Variant('(h)', (fd,)), 297 | Gio.DBusCallFlags.NO_AUTO_START, 298 | -1, 299 | fd_list, 300 | None) 301 | 302 | def stop_live(self): 303 | self.proxy.StopLive() 304 | 305 | def terminate(self): 306 | try: 307 | self.manager.disconnect(self.s1) 308 | except AttributeError: 309 | pass 310 | self._bluez_device.terminate() 311 | super().terminate() 312 | 313 | 314 | class TuhiDBusClientManager(_DBusObject): 315 | __gsignals__ = { 316 | 'unregistered-device': 317 | (GObject.SignalFlags.RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), 318 | } 319 | 320 | def __init__(self): 321 | super().__init__(TUHI_DBUS_NAME, ORG_FREEDESKTOP_TUHI1_MANAGER, ROOT_PATH) 322 | 323 | self._devices = {} 324 | self._unregistered_devices = {} 325 | logger.info('starting up') 326 | 327 | if not self.online: 328 | self.connect('notify::online', self._init) 329 | else: 330 | self._init() 331 | 332 | def _init(self, *args, **kwargs): 333 | logger.info('manager is online') 334 | for objpath in self.property('Devices'): 335 | device = TuhiDBusClientDevice(self, objpath) 336 | self._devices[device.address] = device 337 | 338 | @GObject.Property 339 | def devices(self): 340 | return [v for k, v in self._devices.items()] 341 | 342 | @GObject.Property 343 | def unregistered_devices(self): 344 | return [v for k, v in self._unregistered_devices.items()] 345 | 346 | @GObject.Property 347 | def searching(self): 348 | return self.proxy.get_cached_property('Searching') 349 | 350 | def start_search(self): 351 | self._unregistered_devices = {} 352 | self.proxy.StartSearch() 353 | 354 | def stop_search(self): 355 | try: 356 | self.proxy.StopSearch() 357 | except GLib.Error as e: 358 | if (e.domain != 'g-dbus-error-quark' or 359 | e.code != Gio.IOErrorEnum.EXISTS or 360 | Gio.dbus_error_get_remote_error(e) != 'org.freedesktop.DBus.Error.ServiceUnknown'): 361 | raise e 362 | self._unregistered_devices = {} 363 | 364 | def terminate(self): 365 | for dev in self._devices.values(): 366 | dev.terminate() 367 | self._devices = {} 368 | self._unregistered_devices = {} 369 | super().terminate() 370 | 371 | def _on_properties_changed(self, proxy, changed_props, invalidated_props): 372 | if changed_props is None: 373 | return 374 | 375 | changed_props = changed_props.unpack() 376 | 377 | if 'Devices' in changed_props: 378 | objpaths = changed_props['Devices'] 379 | for objpath in objpaths: 380 | try: 381 | d = self._unregistered_devices[objpath] 382 | self._devices[d.address] = d 383 | del self._unregistered_devices[objpath] 384 | except KeyError: 385 | # if we called Register() on an existing device it's not 386 | # in unregistered devices 387 | pass 388 | self.notify('devices') 389 | if 'Searching' in changed_props: 390 | self.notify('searching') 391 | 392 | def _handle_unregistered_device(self, objpath): 393 | for addr, dev in self._devices.items(): 394 | if dev.objpath == objpath: 395 | self.emit('unregistered-device', dev) 396 | return 397 | 398 | device = TuhiDBusClientDevice(self, objpath) 399 | self._unregistered_devices[objpath] = device 400 | 401 | logger.debug(f'New unregistered device: {device}') 402 | self.emit('unregistered-device', device) 403 | 404 | def _on_signal_received(self, proxy, sender, signal, parameters): 405 | if signal == 'SearchStopped': 406 | self.notify('searching') 407 | elif signal == 'UnregisteredDevice': 408 | objpath = parameters[0] 409 | self._handle_unregistered_device(objpath) 410 | 411 | def __getitem__(self, btaddr): 412 | return self._devices[btaddr] 413 | --------------------------------------------------------------------------------