1#!/usr/local/bin/python3.8
2import getopt
3import gi
4gi.require_version('Gtk', '3.0')
5gi.require_version('XApp', '1.0')
6
7import os
8import sys
9from setproctitle import setproctitle
10import config
11sys.path.append(config.currentPath + "/bin")
12import gettext
13import json
14import importlib.util
15import traceback
16
17from JsonSettingsWidgets import *
18from ExtensionCore import find_extension_subdir
19from gi.repository import Gtk, Gio, XApp
20
21# i18n
22gettext.install("cinnamon", "/usr/local/share/locale")
23
24home = os.path.expanduser("~")
25
26translations = {}
27
28proxy = None
29
30XLET_SETTINGS_WIDGETS = {
31    "entry"             :   "JSONSettingsEntry",
32    "textview"          :   "JSONSettingsTextView",
33    "checkbox"          :   "JSONSettingsSwitch", # deprecated: please use switch instead
34    "switch"            :   "JSONSettingsSwitch",
35    "spinbutton"        :   "JSONSettingsSpinButton",
36    "filechooser"       :   "JSONSettingsFileChooser",
37    "scale"             :   "JSONSettingsRange",
38    "radiogroup"        :   "JSONSettingsComboBox", # deprecated: please use combobox instead
39    "combobox"          :   "JSONSettingsComboBox",
40    "colorchooser"      :   "JSONSettingsColorChooser",
41    "fontchooser"       :   "JSONSettingsFontButton",
42    "soundfilechooser"  :   "JSONSettingsSoundFileChooser",
43    "iconfilechooser"   :   "JSONSettingsIconChooser",
44    "tween"             :   "JSONSettingsTweenChooser",
45    "effect"            :   "JSONSettingsEffectChooser",
46    "datechooser"       :   "JSONSettingsDateChooser",
47    "timechooser"       :   "JSONSettingsTimeChooser",
48    "keybinding"        :   "JSONSettingsKeybinding",
49    "list"              :   "JSONSettingsList"
50}
51
52class XLETSettingsButton(Button):
53    def __init__(self, info, uuid, instance_id):
54        super(XLETSettingsButton, self).__init__(info["description"])
55        self.uuid = uuid
56        self.instance_id = instance_id
57        self.xletCallback = str(info["callback"])
58
59    def on_activated(self):
60        proxy.activateCallback('(sss)', self.xletCallback, self.uuid, self.instance_id)
61
62def translate(uuid, string):
63    #check for a translation for this xlet
64    if uuid not in translations:
65        try:
66            translations[uuid] = gettext.translation(uuid, home + "/.local/share/locale").gettext
67        except IOError:
68            try:
69                translations[uuid] = gettext.translation(uuid, "/usr/local/share/locale").gettext
70            except IOError:
71                translations[uuid] = None
72
73    #do not translate whitespaces
74    if not string.strip():
75        return string
76
77    if translations[uuid]:
78        result = translations[uuid](string)
79
80        try:
81            result = result.decode("utf-8")
82        except (AttributeError, UnicodeDecodeError):
83            result = result
84
85        if result != string:
86            return result
87    return _(string)
88
89class MainWindow(object):
90    def __init__(self, xlet_type, uuid, *instance_id):
91        ## Respecting preview implementation, add the possibility to open a specific tab (if there
92        ## are multiple layouts) and/or a specific instance settings (if there are multiple
93        ## instances of a xlet).
94        ## To do this, two new arguments:
95        ##   -t <n> or --tab=<n>, where <n> is the tab index (starting at 0).
96        ##   -i <id> or --id=<id>, where <id> is the id of the instance.
97        ## Examples, supposing there are two instances of Cinnamenu@json applet, with ids '210' and
98        ## '235' (uncomment line 144 containing print("self.instance_info =", self.instance_info)
99        ## to know all instances ids):
100        ## (Please note that cinnamon-settings is the one offered in #8333)
101        ## cinnamon-settings applets Cinnamenu@json         # opens first tab in first instance
102        ## cinnamon-settings applets Cinnamenu@json '235'   # opens first tab in '235' instance
103        ## cinnamon-settings applets Cinnamenu@json 235     # idem
104        ## cinnamon-settings applets Cinnamenu@json -t 1 -i 235  # opens 2nd tab in '235' instance
105        ## cinnamon-settings applets Cinnamenu@json --tab=1 --id=235  # idem
106        ## cinnamon-settings applets Cinnamenu@json --tab=1 # opens 2nd tab in first instance
107        ## (Also works with 'xlet-settings applet' instead of 'cinnamon-settings applets'.)
108
109        #print("instance_id =", instance_id)
110        self.tab = 0
111        opts = []
112        try:
113            instance_id = int(instance_id[0])
114        except (TypeError, ValueError, IndexError):
115            instance_id = None
116            try:
117                if len(sys.argv) > 3:
118                    opts = getopt.getopt(sys.argv[3:], "t:i:", ["tab=", "id="])[0]
119            except getopt.GetoptError:
120                pass
121            if len(sys.argv) > 4:
122                try:
123                    instance_id = int(sys.argv[4])
124                except ValueError:
125                    instance_id = None
126        #print("opts =", opts)
127        for opt, arg in opts:
128            if opt in ("-t", "--tab"):
129                if arg.isdecimal():
130                    self.tab = int(arg)
131            elif opt in ("-i", "--id"):
132                if arg.isdecimal():
133                    instance_id = int(arg)
134        if instance_id:
135            instance_id = str(instance_id)
136        #print("self.tab =", self.tab)
137        #print("instance_id =", instance_id)
138        self.type = xlet_type
139        self.uuid = uuid
140        self.selected_instance = None
141        self.gsettings = Gio.Settings.new("org.cinnamon")
142        self.custom_modules = {}
143
144        self.load_xlet_data()
145        self.build_window()
146        self.load_instances()
147        #print("self.instance_info =", self.instance_info)
148        self.window.show_all()
149        if instance_id and len(self.instance_info) > 1:
150            for info in self.instance_info:
151                if info["id"] == instance_id:
152                    self.set_instance(info)
153                    break
154        else:
155            self.set_instance(self.instance_info[0])
156        try:
157            Gio.DBusProxy.new_for_bus(Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None,
158                                      "org.Cinnamon", "/org/Cinnamon", "org.Cinnamon", None, self._on_proxy_ready, None)
159        except dbus.exceptions.DBusException as e:
160            print(e)
161
162    def _on_proxy_ready (self, obj, result, data=None):
163        global proxy
164        proxy = Gio.DBusProxy.new_for_bus_finish(result)
165
166        if not proxy.get_name_owner():
167            proxy = None
168
169        if proxy:
170            proxy.highlightXlet('(ssb)', self.uuid, self.selected_instance["id"], True)
171
172    def load_xlet_data (self):
173        self.xlet_dir = "/usr/local/share/cinnamon/%ss/%s" % (self.type, self.uuid)
174        if not os.path.exists(self.xlet_dir):
175            self.xlet_dir = "%s/.local/share/cinnamon/%ss/%s" % (home, self.type, self.uuid)
176
177        if os.path.exists("%s/metadata.json" % self.xlet_dir):
178            raw_data = open("%s/metadata.json" % self.xlet_dir).read()
179            self.xlet_meta = json.loads(raw_data)
180        else:
181            print("Could not find %s metadata for uuid %s - are you sure it's installed correctly?" % (self.type, self.uuid))
182            quit()
183
184    def build_window(self):
185        self.window = XApp.GtkWindow()
186        self.window.set_default_size(800, 600)
187        main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
188        self.window.add(main_box)
189
190        toolbar = Gtk.Toolbar()
191        toolbar.get_style_context().add_class("primary-toolbar")
192        main_box.add(toolbar)
193
194        toolitem = Gtk.ToolItem()
195        toolitem.set_expand(True)
196        toolbar.add(toolitem)
197        toolbutton_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
198        toolitem.add(toolbutton_box)
199        instance_button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
200        instance_button_box.get_style_context().add_class("linked")
201        toolbutton_box.pack_start(instance_button_box, False, False, 0)
202
203        self.prev_button = Gtk.Button.new_from_icon_name('go-previous-symbolic', Gtk.IconSize.BUTTON)
204        self.prev_button.set_tooltip_text(_("Previous instance"))
205        instance_button_box.add(self.prev_button)
206
207        self.next_button = Gtk.Button.new_from_icon_name('go-next-symbolic', Gtk.IconSize.BUTTON)
208        self.next_button.set_tooltip_text(_("Next instance"))
209        instance_button_box.add(self.next_button)
210
211        self.stack_switcher = Gtk.StackSwitcher()
212        toolbutton_box.set_center_widget(self.stack_switcher)
213
214        self.menu_button = Gtk.MenuButton()
215        image = Gtk.Image.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.BUTTON)
216        self.menu_button.add(image)
217        self.menu_button.set_tooltip_text(_("More options"))
218        toolbutton_box.pack_end(self.menu_button, False, False, 0)
219
220        menu = Gtk.Menu()
221        menu.set_halign(Gtk.Align.END)
222
223        restore_option = Gtk.MenuItem(label=_("Import from a file"))
224        menu.append(restore_option)
225        restore_option.connect("activate", self.restore)
226        restore_option.show()
227
228        backup_option = Gtk.MenuItem(label=_("Export to a file"))
229        menu.append(backup_option)
230        backup_option.connect("activate", self.backup)
231        backup_option.show()
232
233        reset_option = Gtk.MenuItem(label=_("Reset to defaults"))
234        menu.append(reset_option)
235        reset_option.connect("activate", self.reset)
236        reset_option.show()
237
238        separator = Gtk.SeparatorMenuItem()
239        menu.append(separator)
240        separator.show()
241
242        reload_option = Gtk.MenuItem(label=_("Reload %s") % self.uuid)
243        menu.append(reload_option)
244        reload_option.connect("activate", self.reload_xlet)
245        reload_option.show()
246
247        self.menu_button.set_popup(menu)
248
249        scw = Gtk.ScrolledWindow()
250        scw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER)
251        main_box.pack_start(scw, True, True, 0)
252        self.instance_stack = Gtk.Stack()
253        scw.add(self.instance_stack)
254
255        if "icon" in self.xlet_meta:
256            self.window.set_icon_name(self.xlet_meta["icon"])
257        else:
258            icon_path = os.path.join(self.xlet_dir, "icon.svg")
259            if os.path.exists(icon_path):
260                self.window.set_icon_from_file(icon_path)
261            else:
262                icon_path = os.path.join(self.xlet_dir, "icon.png")
263                if os.path.exists(icon_path):
264                    self.window.set_icon_from_file(icon_path)
265        self.window.set_title(translate(self.uuid, self.xlet_meta["name"]))
266
267        def check_sizing(widget, data=None):
268            natreq = self.window.get_preferred_size()[1]
269            monitor = Gdk.Display.get_default().get_monitor_at_window(self.window.get_window())
270
271            height = monitor.get_workarea().height
272            if natreq.height > height - 100:
273                self.window.resize(800, 600)
274                scw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
275
276        self.window.connect("destroy", self.quit)
277        self.window.connect("realize", check_sizing)
278        self.prev_button.connect("clicked", self.previous_instance)
279        self.next_button.connect("clicked", self.next_instance)
280
281    def load_instances(self):
282        self.instance_info = []
283        path = "%s/.cinnamon/configs/%s" % (home, self.uuid)
284        instances = 0
285        dir_items = sorted(os.listdir(path))
286        try:
287            multi_instance = int(self.xlet_meta["max-instances"]) != 1
288        except (KeyError, ValueError):
289            multi_instance = False
290
291        for item in dir_items:
292            # ignore anything that isn't json
293            if item[-5:] != ".json":
294                continue
295
296            instance_id = item[0:-5]
297            if not multi_instance and instance_id != self.uuid:
298                continue # for single instance the file name should be [uuid].json
299
300            if multi_instance:
301                try:
302                    int(instance_id)
303                except (TypeError, ValueError):
304                    traceback.print_exc()
305                    continue # multi-instance should have file names of the form [instance-id].json
306
307                instance_exists = False
308                enabled = self.gsettings.get_strv('enabled-%ss' % self.type)
309                for deninition in enabled:
310                    if uuid in deninition and instance_id in deninition.split(':'):
311                        instance_exists = True
312                        break
313
314                if not instance_exists:
315                    continue
316
317            settings = JSONSettingsHandler(os.path.join(path, item), self.notify_dbus)
318            settings.instance_id = instance_id
319            instance_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
320            self.instance_stack.add_named(instance_box, instance_id)
321
322            info = {"settings": settings, "id": instance_id}
323            self.instance_info.append(info)
324
325            settings_map = settings.get_settings()
326            first_key = next(iter(settings_map.values()))
327
328            try:
329                for setting in settings_map:
330                    if setting == "__md5__":
331                        continue
332                    for key in settings_map[setting]:
333                        if key in ("description", "tooltip", "units"):
334                            try:
335                                settings_map[setting][key] = translate(self.uuid, settings_map[setting][key])
336                            except (KeyError, ValueError):
337                                traceback.print_exc()
338                        elif key in "options":
339                            new_opt_data = collections.OrderedDict()
340                            opt_data = settings_map[setting][key]
341                            for option in opt_data:
342                                if opt_data[option] == "custom":
343                                    continue
344                                new_opt_data[translate(self.uuid, option)] = opt_data[option]
345                            settings_map[setting][key] = new_opt_data
346                        elif key in "columns":
347                            columns_data = settings_map[setting][key]
348                            for column in columns_data:
349                                column["title"] = translate(self.uuid, column["title"])
350            finally:
351                # if a layout is not explicitly defined, generate the settings
352                # widgets based on the order they occur
353                if first_key["type"] == "layout":
354                    self.build_with_layout(settings_map, info, instance_box, first_key)
355                else:
356                    self.build_from_order(settings_map, info, instance_box, first_key)
357
358                if self.selected_instance is None:
359                    self.selected_instance = info
360                    if "stack" in info:
361                        self.stack_switcher.set_stack(info["stack"])
362
363            instances += 1
364
365        if instances < 2:
366            self.prev_button.set_no_show_all(True)
367            self.next_button.set_no_show_all(True)
368
369    def build_with_layout(self, settings_map, info, box, first_key):
370        layout = first_key
371
372        page_stack = SettingsStack()
373        box.pack_start(page_stack, True, True, 0)
374        self.stack_switcher.show()
375        info["stack"] = page_stack
376
377        for page_key in layout["pages"]:
378            page_def = layout[page_key]
379            if page_def['type'] == 'custom':
380                page = self.create_custom_widget(page_def, info['settings'])
381                if page is None:
382                    continue
383                elif not isinstance(page, SettingsPage):
384                    print('page is not of type SettingsPage')
385                    continue
386            else:
387                page = SettingsPage()
388                for section_key in page_def["sections"]:
389                    section_def = layout[section_key]
390                    if 'dependency' in section_def:
391                        revealer = JSONSettingsRevealer(info['settings'], section_def['dependency'])
392                        section = page.add_reveal_section(translate(self.uuid, section_def["title"]), revealer=revealer)
393                    else:
394                        section = page.add_section(translate(self.uuid, section_def["title"]))
395                    for key in section_def["keys"]:
396                        item = settings_map[key]
397                        settings_type = item["type"]
398                        if settings_type == "button":
399                            widget = XLETSettingsButton(item, self.uuid, info["id"])
400                        elif settings_type == "label":
401                            widget = Text(translate(self.uuid, item["description"]))
402                        elif settings_type == 'custom':
403                            widget = self.create_custom_widget(item, key, info['settings'])
404                            if widget is None:
405                                continue
406                            elif not isinstance(widget, SettingsWidget):
407                                print('widget is not of type SettingsWidget')
408                                continue
409                        elif settings_type in XLET_SETTINGS_WIDGETS:
410                            widget = globals()[XLET_SETTINGS_WIDGETS[settings_type]](key, info["settings"], item)
411                        else:
412                            continue
413
414                        if 'dependency' in item:
415                            revealer = JSONSettingsRevealer(info['settings'], item['dependency'])
416                            section.add_reveal_row(widget, revealer=revealer)
417                        else:
418                            section.add_row(widget)
419            page_stack.add_titled(page, page_key, translate(self.uuid, page_def["title"]))
420
421    def build_from_order(self, settings_map, info, box, first_key):
422        page = SettingsPage()
423        box.pack_start(page, True, True, 0)
424
425        # if the first key is not of type 'header' or type 'section' we need to make a new section
426        if first_key["type"] not in ("header", "section"):
427            section = page.add_section(_("Settings for %s") % self.uuid)
428
429        for key, item in settings_map.items():
430            if key == "__md5__":
431                continue
432            if "type" in item:
433                settings_type = item["type"]
434                if settings_type in ("header", "section"):
435                    if 'dependency' in item:
436                        revealer = JSONSettingsRevealer(info['settings'], item['dependency'])
437                        section = page.add_reveal_section(translate(self.uuid, item["description"]), revealer=revealer)
438                    else:
439                        section = page.add_section(translate(self.uuid, item["description"]))
440                    continue
441
442                if settings_type == "button":
443                    widget = XLETSettingsButton(item, self.uuid, info["id"])
444                elif settings_type == "label":
445                    widget = Text(translate(self.uuid, item["description"]))
446                elif settings_type == 'custom':
447                    widget = self.create_custom_widget(item, key, info['settings'])
448                    if widget is None:
449                        continue
450                    elif not isinstance(widget, SettingsWidget):
451                        print('widget is not of type SettingsWidget')
452                        continue
453                elif settings_type in XLET_SETTINGS_WIDGETS:
454                    widget = globals()[XLET_SETTINGS_WIDGETS[settings_type]](key, info["settings"], item)
455                else:
456                    continue
457
458                if 'dependency' in item:
459                    revealer = JSONSettingsRevealer(info['settings'], item['dependency'])
460                    section.add_reveal_row(widget, revealer=revealer)
461                else:
462                    section.add_row(widget)
463
464    def create_custom_widget(self, info, *args):
465        file_name = info['file']
466        widget_name = info['widget']
467        file_path = os.path.join(find_extension_subdir(self.xlet_dir), file_name)
468
469        try:
470            if file_name not in self.custom_modules:
471                spec = importlib.util.spec_from_file_location(self.uuid.replace('@', '') + '.' + file_name.split('.')[0], file_path)
472                module = importlib.util.module_from_spec(spec)
473                spec.loader.exec_module(module)
474                self.custom_modules[file_name] = module
475
476        except KeyError:
477            traceback.print_exc()
478            print('problem loading custom widget')
479            return None
480
481        return getattr(self.custom_modules[file_name], widget_name)(info, *args)
482
483    def notify_dbus(self, handler, key, value):
484        proxy.updateSetting('(ssss)', self.uuid, handler.instance_id, key, json.dumps(value))
485
486    def set_instance(self, info):
487        self.instance_stack.set_visible_child_name(info["id"])
488        if "stack" in info:
489            self.stack_switcher.set_stack(info["stack"])
490            children = info["stack"].get_children()
491            if len(children) > 1:
492                if self.tab in range(len(children)):
493                    info["stack"].set_visible_child(children[self.tab])
494                else:
495                    info["stack"].set_visible_child(children[0])
496        if proxy:
497            proxy.highlightXlet('(ssb)', self.uuid, self.selected_instance["id"], False)
498            proxy.highlightXlet('(ssb)', self.uuid, info["id"], True)
499        self.selected_instance = info
500
501    def previous_instance(self, *args):
502        self.instance_stack.set_transition_type(Gtk.StackTransitionType.OVER_RIGHT)
503        index = self.instance_info.index(self.selected_instance)
504        self.set_instance(self.instance_info[index-1])
505
506    def next_instance(self, *args):
507        self.instance_stack.set_transition_type(Gtk.StackTransitionType.OVER_LEFT)
508        index = self.instance_info.index(self.selected_instance)
509        if index == len(self.instance_info) - 1:
510            index = 0
511        else:
512            index +=1
513        self.set_instance(self.instance_info[index])
514
515    # def unpack_args(self, args):
516    #    args = {}
517
518    def backup(self, *args):
519        dialog = Gtk.FileChooserDialog(_("Select or enter file to export to"),
520                                       None,
521                                       Gtk.FileChooserAction.SAVE,
522                                       (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
523                                        Gtk.STOCK_SAVE, Gtk.ResponseType.ACCEPT))
524        dialog.set_do_overwrite_confirmation(True)
525        filter_text = Gtk.FileFilter()
526        filter_text.add_pattern("*.json")
527        filter_text.set_name(_("JSON files"))
528        dialog.add_filter(filter_text)
529
530        response = dialog.run()
531
532        if response == Gtk.ResponseType.ACCEPT:
533            filename = dialog.get_filename()
534            if ".json" not in filename:
535                filename = filename + ".json"
536            self.selected_instance["settings"].save_to_file(filename)
537
538        dialog.destroy()
539
540    def restore(self, *args):
541        dialog = Gtk.FileChooserDialog(_("Select a JSON file to import"),
542                                       None,
543                                       Gtk.FileChooserAction.OPEN,
544                                       (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
545                                        Gtk.STOCK_OPEN, Gtk.ResponseType.OK))
546        filter_text = Gtk.FileFilter()
547        filter_text.add_pattern("*.json")
548        filter_text.set_name(_("JSON files"))
549        dialog.add_filter(filter_text)
550
551        response = dialog.run()
552
553        if response == Gtk.ResponseType.OK:
554            filename = dialog.get_filename()
555            self.selected_instance["settings"].load_from_file(filename)
556
557        dialog.destroy()
558
559    def reset(self, *args):
560        self.selected_instance["settings"].reset_to_defaults()
561
562    def reload_xlet(self, *args):
563        if proxy:
564            proxy.ReloadXlet('(ss)', self.uuid, self.type.upper())
565
566    def quit(self, *args):
567        if proxy:
568            proxy.highlightXlet('(ssb)', self.uuid, self.selected_instance["id"], False)
569
570        self.window.destroy()
571        Gtk.main_quit()
572
573if __name__ == "__main__":
574    setproctitle("xlet-settings")
575    import signal
576    if len(sys.argv) < 3:
577        print("Error: requires type and uuid")
578        quit()
579    xlet_type = sys.argv[1]
580    if xlet_type not in ["applet", "desklet", "extension"]:
581        print("Error: Invalid xlet type %s", sys.argv[1])
582        quit()
583    uuid = sys.argv[2]
584    window = MainWindow(xlet_type, uuid, *sys.argv[3:])
585    signal.signal(signal.SIGINT, window.quit)
586    Gtk.main()
587