1#!/usr/local/bin/python3.8
2
3# Todo:
4# - TextTag.invisible does not work nicely with scrollheight, find out why
5#   - (Sometimes scrollbars think there is more or less to scroll than there actually is after
6#     showing/hiding entries in page_log.py)
7# - Add insert button to "simple types" inspect dialog ? is there actual use for these types
8#   inserted as results ?
9# - Load all enabled log categories and window height from gsettings
10# - Make CommandLine entry & history work more like a normal terminal
11#   - When navigating through history and modifying a line
12#   - When pressing ctrl + r, search history
13#   - auto-completion ?
14
15import os
16import signal
17import sys
18import dbus
19import dbus.service
20from dbus.mainloop.glib import DBusGMainLoop
21import pyinotify
22import gi
23gi.require_version('Gtk', '3.0')
24from gi.repository import Gio, Gtk, GObject, Gdk, GLib
25from setproctitle import setproctitle
26
27import pageutils
28from lookingglass_proxy import LookingGlassProxy
29
30signal.signal(signal.SIGINT, signal.SIG_DFL)
31
32MELANGE_DBUS_NAME = "org.Cinnamon.Melange"
33MELANGE_DBUS_PATH = "/org/Cinnamon/Melange"
34
35class MenuButton(Gtk.Button):
36    def __init__(self, text):
37        Gtk.Button.__init__(self, text)
38        self.menu = None
39        self.connect("clicked", self.on_clicked)
40
41    def set_popup(self, menu):
42        self.menu = menu
43
44    def on_clicked(self, widget):
45        x, y, w, h = self.get_screen_coordinates()
46        self.menu.popup(None, None, lambda menu, data: (x, y+h, True), None, 1, 0)
47
48    def get_screen_coordinates(self):
49        parent = self.get_parent_window()
50        x, y = parent.get_root_origin()
51        w = parent.get_width()
52        h = parent.get_height()
53        extents = parent.get_frame_extents()
54        allocation = self.get_allocation()
55        return (x + (extents.width-w)//2 + allocation.x,
56                y + (extents.height-h)-(extents.width-w)//2 + allocation.y,
57                allocation.width,
58                allocation.height)
59
60class CommandLine(Gtk.Entry):
61    def __init__(self, exec_cb):
62        Gtk.Entry.__init__(self)
63        self.exec_cb = exec_cb
64        self.settings = Gio.Settings.new("org.cinnamon")
65        self.history = self.settings.get_strv("looking-glass-history")
66        self.history_position = -1
67        self.last_text = ""
68        self.connect('key-press-event', self.on_key_press)
69        self.connect("populate-popup", self.populate_popup)
70
71    def populate_popup(self, view, menu):
72        menu.append(Gtk.SeparatorMenuItem())
73        clear = Gtk.MenuItem("Clear History")
74        clear.connect('activate', self.history_clear)
75        menu.append(clear)
76        menu.show_all()
77        return False
78
79    def on_key_press(self, widget, event):
80        if event.keyval == Gdk.KEY_Up:
81            self.history_prev()
82            return True
83        if event.keyval == Gdk.KEY_Down:
84            self.history_next()
85            return True
86        if event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter:
87            self.execute()
88            return True
89
90    def history_clear(self, menu_item):
91        self.history = []
92        self.history_position = -1
93        self.last_text = ""
94        self.settings.set_strv("looking-glass-history", self.history)
95
96    def history_prev(self):
97        num = len(self.history)
98        if self.history_position == 0 or num == 0:
99            return
100        if self.history_position == -1:
101            self.history_position = num - 1
102            self.last_text = self.get_text()
103        else:
104            self.history_position -= 1
105        self.set_text(self.history[self.history_position])
106        self.select_region(-1, -1)
107
108    def history_next(self):
109        if self.history_position == -1:
110            return
111        num = len(self.history)
112        if self.history_position == num-1:
113            self.history_position = -1
114            self.set_text(self.last_text)
115        else:
116            self.history_position += 1
117            self.set_text(self.history[self.history_position])
118        self.select_region(-1, -1)
119
120    def execute(self):
121        self.history_position = -1
122        command = self.get_text()
123        if command != "":
124            num = len(self.history)
125            if num == 0 or self.history[num-1] != command:
126                self.history.append(command)
127            self.set_text("")
128            self.settings.set_strv("looking-glass-history", self.history)
129
130            self.exec_cb(command)
131
132
133class NewLogDialog(Gtk.Dialog):
134    def __init__(self, parent):
135        Gtk.Dialog.__init__(self, "Add a new file watcher", parent, 0,
136                            (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
137                             Gtk.STOCK_OK, Gtk.ResponseType.OK))
138
139        self.set_default_size(150, 100)
140
141        label = Gtk.Label("")
142        label.set_markup("<span size='large'>Add File Watch:</span>\n\n" +
143                         "Please select a file to watch and a name for the tab\n")
144
145        box = self.get_content_area()
146        box.add(label)
147
148        self.store = Gtk.ListStore(str, str)
149        self.store.append(["glass.log", "~/.cinnamon/glass.log"])
150        self.store.append(["custom", "<Select file>"])
151
152        self.combo = Gtk.ComboBox.new_with_model(self.store)
153        self.combo.connect("changed", self.on_combo_changed)
154        renderer_text = Gtk.CellRendererText()
155        self.combo.pack_start(renderer_text, True)
156        self.combo.add_attribute(renderer_text, "text", 1)
157
158        table = Gtk.Table(2, 2, False)
159        table.attach(Gtk.Label(label="File: ", halign=Gtk.Align.START), 0, 1, 0, 1)
160        table.attach(self.combo, 1, 2, 0, 1)
161        table.attach(Gtk.Label(label="Name: ", halign=Gtk.Align.START), 0, 1, 1, 2)
162        self.entry = Gtk.Entry()
163        table.attach(self.entry, 1, 2, 1, 2)
164
165        self.filename = None
166        box.add(table)
167        self.show_all()
168
169    def on_combo_changed(self, combo):
170        tree_iter = combo.get_active_iter()
171        if tree_iter is not None:
172            model = combo.get_model()
173            name, self.filename = model[tree_iter][:2]
174            self.entry.set_text(name)
175            if name == "custom":
176                new_file = self.select_file()
177                if new_file is not None:
178                    combo.set_active_iter(self.store.insert(1, ["user", new_file]))
179                else:
180                    combo.set_active(-1)
181            return False
182
183    def is_valid(self):
184        return (self.entry.get_text() != "" and
185                self.filename is not None and
186                os.path.isfile(os.path.expanduser(self.filename)))
187
188    def get_file(self):
189        return os.path.expanduser(self.filename)
190
191    def get_text(self):
192        return self.entry.get_text()
193
194    def select_file(self):
195        dialog = Gtk.FileChooserDialog("Please select a log file", self,
196                                       Gtk.FileChooserAction.OPEN,
197                                       (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
198                                        Gtk.STOCK_OPEN, Gtk.ResponseType.OK))
199
200        filter_text = Gtk.FileFilter()
201        filter_text.set_name("Text files")
202        filter_text.add_mime_type("text/plain")
203        dialog.add_filter(filter_text)
204
205        filter_any = Gtk.FileFilter()
206        filter_any.set_name("Any files")
207        filter_any.add_pattern("*")
208        dialog.add_filter(filter_any)
209
210        response = dialog.run()
211        result = None
212        if response == Gtk.ResponseType.OK:
213            result = dialog.get_filename()
214        dialog.destroy()
215
216        return result
217
218class FileWatchHandler(pyinotify.ProcessEvent):
219    def my_init(self, view):
220        self.view = view
221
222    def process_IN_CLOSE_WRITE(self, event):
223        self.view.get_updates()
224
225    def process_IN_CREATE(self, event):
226        self.view.get_updates()
227
228    def process_IN_DELETE(self, event):
229        self.view.get_updates()
230
231    def process_IN_MODIFY(self, event):
232        self.view.get_updates()
233
234class FileWatcherView(Gtk.ScrolledWindow):
235    def __init__(self, filename):
236        Gtk.ScrolledWindow.__init__(self)
237
238        self.filename = filename
239        self.changed = 0
240        self.update_id = 0
241        self.set_shadow_type(Gtk.ShadowType.ETCHED_IN)
242        self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
243
244        self.textview = Gtk.TextView()
245        self.textview.set_editable(False)
246        self.add(self.textview)
247
248        self.textbuffer = self.textview.get_buffer()
249
250        self.show_all()
251        self.get_updates()
252
253        handler = FileWatchHandler(view=self)
254        watch_manager = pyinotify.WatchManager()
255        self.notifier = pyinotify.ThreadedNotifier(watch_manager, handler)
256        watch_manager.add_watch(filename, (pyinotify.IN_CLOSE_WRITE |
257                                           pyinotify.IN_CREATE |
258                                           pyinotify.IN_DELETE |
259                                           pyinotify.IN_MODIFY))
260        self.notifier.start()
261        self.connect("destroy", self.on_destroy)
262        self.connect("size-allocate", self.on_size_changed)
263
264    def on_destroy(self, widget):
265        if self.notifier:
266            self.notifier.stop()
267            self.notifier = None
268
269    def on_size_changed(self, widget, bla):
270        if self.changed > 0:
271            end_iter = self.textbuffer.get_end_iter()
272            self.textview.scroll_to_iter(end_iter, 0, False, 0, 0)
273            self.changed -= 1
274
275    def get_updates(self):
276        # only update 2 times per second max
277        # without this rate limiting, certain file modifications can cause
278        # a crash at Gtk.TextBuffer.set_text()
279        if self.update_id == 0:
280            self.update_id = GLib.timeout_add(500, self.update)
281
282    def update(self):
283        self.changed = 2 # on_size_changed will be called twice, but only the second time is final
284        self.textbuffer.set_text(open(self.filename, 'r').read())
285        self.update_id = 0
286        return False
287
288class ClosableTabLabel(Gtk.Box):
289    __gsignals__ = {
290        "close-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
291    }
292    def __init__(self, label_text):
293        Gtk.Box.__init__(self)
294        self.set_orientation(Gtk.Orientation.HORIZONTAL)
295        self.set_spacing(5)
296
297        label = Gtk.Label(label_text)
298        self.pack_start(label, True, True, 0)
299
300        button = Gtk.Button()
301        button.set_relief(Gtk.ReliefStyle.NONE)
302        button.set_focus_on_click(False)
303        button.add(Gtk.Image.new_from_stock(Gtk.STOCK_CLOSE, Gtk.IconSize.MENU))
304        button.connect("clicked", self.button_clicked)
305        self.pack_start(button, False, False, 0)
306
307        self.show_all()
308
309    def button_clicked(self, button, data=None):
310        self.emit("close-clicked")
311
312class MelangeApp(dbus.service.Object):
313    def __init__(self):
314        self.lg_proxy = LookingGlassProxy()
315        # The status label is shown iff we are not okay
316        self.lg_proxy.add_status_change_callback(lambda x: self.status_label.set_visible(not x))
317
318        self.window = None
319        self._minimized = False
320        self.run()
321
322        dbus.service.Object.__init__(self, dbus.SessionBus(), MELANGE_DBUS_PATH, MELANGE_DBUS_NAME)
323
324    @dbus.service.method(MELANGE_DBUS_NAME, in_signature='', out_signature='')
325    def show(self):
326        if self.window.get_visible():
327            if self._minimized:
328                self.window.present()
329            else:
330                self.window.hide()
331        else:
332            self.show_and_focus()
333
334    @dbus.service.method(MELANGE_DBUS_NAME, in_signature='', out_signature='')
335    def hide(self):
336        self.window.hide()
337
338    @dbus.service.method(MELANGE_DBUS_NAME, in_signature='', out_signature='b')
339    def getVisible(self):
340        return self.window.get_visible()
341
342    @dbus.service.method(MELANGE_DBUS_NAME, in_signature='', out_signature='')
343    def doInspect(self):
344        if self.lg_proxy:
345            self.lg_proxy.StartInspector()
346            self.window.hide()
347
348    def show_and_focus(self):
349        self.window.show_all()
350        self.lg_proxy.refresh_status()
351        self.command_line.grab_focus()
352
353    def run(self):
354        self.window = Gtk.Window(type=Gtk.WindowType.TOPLEVEL)
355        self.window.set_title("Melange")
356        self.window.set_icon_name("system-search")
357        self.window.set_default_size(1000, 400)
358        self.window.set_position(Gtk.WindowPosition.MOUSE)
359
360        # I can't think of a way to reliably detect if the window
361        # is active to determine if we need to present or hide
362        # in show(). Since the window briefly loses focus during
363        # shortcut press we'd be unable to detect it at that time.
364        # Keeping the window on top ensures the window is never
365        # obscured so we can just hide if visible.
366        self.window.set_keep_above(True)
367
368        self.window.connect("delete_event", self.on_delete)
369        self.window.connect("key-press-event", self.on_key_press)
370        self._minimized = False
371        self.window.connect("window-state-event", self.on_window_state)
372
373        num_rows = 3
374        num_columns = 6
375        table = Gtk.Table(n_rows=num_rows, n_columns=num_columns, homogeneous=False)
376        table.set_margin_start(6)
377        table.set_margin_end(6)
378        table.set_margin_top(6)
379        table.set_margin_bottom(6)
380        self.window.add(table)
381
382        self.notebook = Gtk.Notebook()
383        self.notebook.set_tab_pos(Gtk.PositionType.BOTTOM)
384        self.notebook.show()
385        self.notebook.set_show_border(True)
386        self.notebook.set_show_tabs(True)
387
388        label = Gtk.Label(label="Melange")
389        label.set_markup("<u>Melange - Cinnamon Debugger</u> ")
390        label.show()
391        self.notebook.set_action_widget(label, Gtk.PackType.END)
392
393        self.pages = {}
394        self.custom_pages = {}
395        self.create_page("Results", "results")
396        self.create_page("Inspect", "inspect")
397        self.create_page("Windows", "windows")
398        self.create_page("Extensions", "extensions")
399        self.create_page("Log", "log")
400
401        table.attach(self.notebook, 0, num_columns, 0, 1)
402
403        column = 0
404        picker_button = pageutils.ImageButton("color-select-symbolic")
405        picker_button.set_tooltip_text("Select an actor to inspect")
406        picker_button.connect("clicked", self.on_picker_clicked)
407        table.attach(picker_button, column, column+1, 1, 2, 0, 0, 2)
408        column += 1
409
410        full_gc = pageutils.ImageButton("user-trash-full-symbolic")
411        full_gc.set_tooltip_text("Invoke garbage collection")
412        # ignore signal arg
413        full_gc.connect('clicked', lambda source: self.lg_proxy.FullGc())
414        table.attach(full_gc, column, column+1, 1, 2, 0, 0, 2)
415        column += 1
416
417        self.command_line = CommandLine(self.lg_proxy.Eval)
418        self.command_line.set_tooltip_text("Evaluate javascript")
419        table.attach(self.command_line,
420                     column,
421                     column + 1,
422                     1,
423                     2,
424                     Gtk.AttachOptions.EXPAND | Gtk.AttachOptions.FILL,
425                     0,
426                     3,
427                     2)
428        column += 1
429
430        self.status_label = Gtk.Label(label="Status")
431        self.status_label.set_markup(" <span foreground='red'>[ Cinnamon is OFFLINE! ]</span> ")
432        self.status_label.set_tooltip_text("The connection to cinnamon is broken")
433        self.status_label.set_no_show_all(True)
434        table.attach(self.status_label, column, column+1, 1, 2, 0, 0, 1)
435        column += 1
436
437        box = Gtk.HBox()
438        settings = Gio.Settings(schema="org.cinnamon.desktop.keybindings")
439        arr = settings.get_strv("looking-glass-keybinding")
440        if len(arr) > 0:
441            # only the first mapped keybinding
442            [accel_key, mask] = Gtk.accelerator_parse(arr[0])
443            if accel_key == 0 and mask == 0:
444                # failed to parse, fallback to plain accel string
445                label = Gtk.Label(label=arr[0])
446            else:
447                label = Gtk.Label(label=Gtk.accelerator_get_label(accel_key, mask))
448            label.set_tooltip_text("Toggle shortcut")
449            box.pack_start(label, False, False, 3)
450
451        action_button = self.create_action_button()
452        box.pack_start(action_button, False, False, 3)
453
454        table.attach(box, column, column+1, 1, 2, 0, 0, 1)
455
456        self.activate_page("results")
457        self.status_label.hide()
458        self.window.set_focus(self.command_line)
459
460    def create_menu_item(self, text, callback):
461        item = Gtk.MenuItem(label=text)
462        item.connect("activate", callback)
463        return item
464
465    def create_action_button(self):
466        restart_func = lambda junk: os.system("nohup cinnamon --replace > /dev/null 2>&1 &")
467        crash_func = lambda junk: self.lg_proxy.Eval("global.segfault()")
468
469        menu = Gtk.Menu()
470        menu.append(self.create_menu_item('Add File Watcher', self.on_add_file_watcher))
471        menu.append(Gtk.SeparatorMenuItem())
472        menu.append(self.create_menu_item('Restart Cinnamon', restart_func))
473        menu.append(self.create_menu_item('Crash Cinnamon', crash_func))
474        menu.append(self.create_menu_item('Reset Cinnamon Settings', self.on_reset_clicked))
475        menu.append(Gtk.SeparatorMenuItem())
476        menu.append(self.create_menu_item('About Melange', self.on_about_clicked))
477        menu.append(self.create_menu_item('Quit', self.on_delete))
478        menu.show_all()
479
480        button = Gtk.MenuButton(label="Actions \u25BE")
481        button.set_popup(menu)
482        return button
483
484    def on_add_file_watcher(self, menu_item):
485        dialog = NewLogDialog(self.window)
486        response = dialog.run()
487
488        if response == Gtk.ResponseType.OK and dialog.is_valid():
489            label = ClosableTabLabel(dialog.get_text())
490            content = FileWatcherView(dialog.get_file())
491            content.show()
492            label.connect("close-clicked", self.on_close_tab, content)
493            self.custom_pages[label] = content
494            self.notebook.append_page(content, label)
495            self.notebook.set_current_page(self.notebook.get_n_pages()-1)
496
497        dialog.destroy()
498
499    def on_close_tab(self, label, content):
500        self.notebook.remove_page(self.notebook.page_num(content))
501        content.destroy()
502        del self.custom_pages[label]
503
504    def on_about_clicked(self, menu_item):
505        dialog = Gtk.MessageDialog(self.window, 0,
506                                   Gtk.MessageType.QUESTION, Gtk.ButtonsType.CLOSE)
507
508        dialog.set_title("About Melange")
509        dialog.set_markup("""\
510<b>Melange</b> is a GTK3 alternative to the built-in javascript debugger <i>Looking Glass</i>
511
512Pressing <i>Escape</i> while Melange has focus will hide the window.
513If you want to exit Melange, use ALT+F4 or the <u>Actions</u> menu button.
514
515If you defined a hotkey for Melange, pressing it while Melange is visible it will be hidden.""")
516
517        dialog.run()
518        dialog.destroy()
519
520    def on_reset_clicked(self, menu_item):
521        dialog = Gtk.MessageDialog(self.window, 0,
522                                   Gtk.MessageType.WARNING, Gtk.ButtonsType.YES_NO,
523                                   "Reset all cinnamon settings to default?")
524        dialog.set_title("Warning: Trying to reset all cinnamon settings!")
525
526        response = dialog.run()
527        dialog.destroy()
528        if response == Gtk.ResponseType.YES:
529            os.system("gsettings reset-recursively org.cinnamon &")
530
531    def on_key_press(self, widget, event=None):
532        if event.keyval == Gdk.KEY_Escape:
533            self.window.hide()
534
535    def on_delete(self, widget=None, event=None):
536        tmp_pages = self.custom_pages.copy()
537        for label, content in tmp_pages.items():
538            self.on_close_tab(label, content)
539        Gtk.main_quit()
540        return False
541
542    def on_window_state(self, widget, event):
543        if event.new_window_state & Gdk.WindowState.ICONIFIED:
544            self._minimized = True
545        else:
546            self._minimized = False
547
548    def on_picker_clicked(self, widget):
549        self.lg_proxy.StartInspector()
550        self.window.hide()
551
552    def create_dummy_page(self, text, description):
553        label = Gtk.Label(label=text)
554        self.notebook.append_page(Gtk.Label(label=description), label)
555
556    def create_page(self, text, module_name):
557        module = __import__("page_%s" % module_name)
558        module.lg_proxy = self.lg_proxy
559        module.melangeApp = self
560        label = Gtk.Label(label=text)
561        page = module.ModulePage(self)
562        self.pages[module_name] = page
563        self.notebook.append_page(page, label)
564
565    def activate_page(self, module_name):
566        page = self.notebook.page_num(self.pages[module_name])
567        self.notebook.set_current_page(page)
568
569def main():
570    setproctitle("cinnamon-looking-glass")
571    DBusGMainLoop(set_as_default=True)
572
573    session_bus = dbus.SessionBus()
574    request = session_bus.request_name(MELANGE_DBUS_NAME, dbus.bus.NAME_FLAG_DO_NOT_QUEUE)
575    if request != dbus.bus.REQUEST_NAME_REPLY_EXISTS:
576        app = MelangeApp()
577    else:
578        dbus_obj = session_bus.get_object(MELANGE_DBUS_NAME, MELANGE_DBUS_PATH)
579        app = dbus.Interface(dbus_obj, MELANGE_DBUS_NAME)
580
581    daemon = len(sys.argv) == 2 and sys.argv[1] == "daemon"
582    inspect = len(sys.argv) == 2 and sys.argv[1] == "inspect"
583
584    if inspect:
585        app.doInspect()
586    elif not daemon:
587        app.show()
588
589    Gtk.main()
590
591if __name__ == "__main__":
592    main()
593