Sexy system trac icon instead of ugly console, Total received/sent stat, List all site peer, Dont sent passive peers over pex, Dont store passive peers on trackers, Dont monkey patch thread at all, Allow main command plugins
This commit is contained in:
parent
804fed2659
commit
30281c8fb5
14 changed files with 1170 additions and 207 deletions
|
@ -53,12 +53,14 @@ class UiRequestPlugin(object):
|
|||
|
||||
# Memory
|
||||
try:
|
||||
yield "Ip external: %s | " % config.ip_external
|
||||
yield "Port opened: %s | " % main.file_server.port_opened
|
||||
yield "IP external: %s | " % config.ip_external
|
||||
yield "Opened: %s | " % main.file_server.port_opened
|
||||
yield "Recv: %.2fMB, Sent: %.2fMB | " % (float(main.file_server.bytes_recv)/1024/1024, float(main.file_server.bytes_sent)/1024/1024)
|
||||
yield "Peerid: %s | " % main.file_server.peer_id
|
||||
import psutil
|
||||
process = psutil.Process(os.getpid())
|
||||
mem = process.get_memory_info()[0] / float(2 ** 20)
|
||||
yield "Memory usage: %.2fMB | " % mem
|
||||
yield "Mem: %.2fMB | " % mem
|
||||
yield "Threads: %s | " % len(process.threads())
|
||||
yield "CPU: usr %.2fs sys %.2fs | " % process.cpu_times()
|
||||
yield "Open files: %s | " % len(process.open_files())
|
||||
|
@ -69,7 +71,7 @@ class UiRequestPlugin(object):
|
|||
yield "<br>"
|
||||
|
||||
# Connections
|
||||
yield "<b>Connections</b> (%s):<br>" % len(main.file_server.connections)
|
||||
yield "<b>Connections</b> (%s, total made: %s):<br>" % (len(main.file_server.connections), main.file_server.last_connection_id)
|
||||
yield "<table><tr> <th>id</th> <th>protocol</th> <th>type</th> <th>ip</th> <th>open</th> <th>ping</th> <th>buff</th>"
|
||||
yield "<th>idle</th> <th>open</th> <th>delay</th> <th>sent</th> <th>received</th> <th>last sent</th> <th>waiting</th> <th>version</th> <th>peerid</th> </tr>"
|
||||
for connection in main.file_server.connections:
|
||||
|
@ -100,11 +102,15 @@ class UiRequestPlugin(object):
|
|||
yield "<tr><th>address</th> <th>connected</th> <th>peers</th> <th>content.json</th> </tr>"
|
||||
for site in self.server.sites.values():
|
||||
yield self.formatTableRow([
|
||||
("%s", site.address),
|
||||
("<a href='#ShowPeers' onclick='document.getElementById(\"peers_%s\").style.display=\"initial\"; return false'>%s</a>", (site.address, site.address)),
|
||||
("%s", [peer.connection.id for peer in site.peers.values() if peer.connection and peer.connection.connected]),
|
||||
("%s/%s", ( len([peer for peer in site.peers.values() if peer.connection and peer.connection.connected]), len(site.peers) ) ),
|
||||
("%s", len(site.content_manager.contents)),
|
||||
])
|
||||
yield "<tr><td id='peers_%s' style='display: none'>" % site.address
|
||||
for key, peer in site.peers.items():
|
||||
yield "(%s) %s -<br>" % (peer.connection, key)
|
||||
yield "<br></td></tr>"
|
||||
yield "</table>"
|
||||
|
||||
|
||||
|
|
91
plugins/Trayicon/TrayiconPlugin.py
Normal file
91
plugins/Trayicon/TrayiconPlugin.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
import re, time, cgi, os, sys
|
||||
from Plugin import PluginManager
|
||||
from Config import config
|
||||
import atexit
|
||||
|
||||
allow_reload = False # No reload supported
|
||||
|
||||
@PluginManager.registerTo("Actions")
|
||||
class ActionsPlugin(object):
|
||||
def main(self):
|
||||
global notificationicon, winfolders
|
||||
from lib import notificationicon, winfolders
|
||||
import gevent.threadpool
|
||||
|
||||
self.main = sys.modules["main"]
|
||||
|
||||
icon = notificationicon.NotificationIcon(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'trayicon.ico'), "ZeroNet %s" % config.version)
|
||||
self.icon = icon
|
||||
|
||||
if not config.debug: # Hide console if not in debug mode
|
||||
notificationicon.hideConsole()
|
||||
self.console = False
|
||||
else:
|
||||
self.console = True
|
||||
|
||||
@atexit.register
|
||||
def hideIcon():
|
||||
icon.die()
|
||||
|
||||
icon.items = (
|
||||
(self.titleIp, False),
|
||||
(self.titleConnections, False),
|
||||
(self.titleTransfer, False),
|
||||
(self.titleConsole, self.toggleConsole),
|
||||
"--",
|
||||
("ZeroNet Twitter", lambda: self.opensite("https://twitter.com/HelloZeroNet") ),
|
||||
("ZeroNet Reddit", lambda: self.opensite("http://www.reddit.com/r/zeronet/") ),
|
||||
("ZeroNet Github", lambda: self.opensite("https://github.com/HelloZeroNet/ZeroNet") ),
|
||||
("Report bug/request feature", lambda: self.opensite("https://github.com/HelloZeroNet/ZeroNet/issues") ),
|
||||
"--",
|
||||
("!Open ZeroNet", lambda: self.opensite("http://%s:%s" % (config.ui_ip, config.ui_port)) ),
|
||||
#"--",
|
||||
#("Start ZeroNet when Windows starts", quit),
|
||||
"--",
|
||||
("Quit", self.quit),
|
||||
|
||||
)
|
||||
|
||||
icon.clicked = lambda: self.opensite("http://%s:%s" % (config.ui_ip, config.ui_port))
|
||||
gevent.threadpool.start_new_thread(icon._run, ()) # Start in real thread (not gevent compatible)
|
||||
super(ActionsPlugin, self).main()
|
||||
icon._die = True
|
||||
|
||||
def quit(self):
|
||||
self.icon.die()
|
||||
time.sleep(0.1)
|
||||
self.main.ui_server.stop()
|
||||
self.main.file_server.stop()
|
||||
#sys.exit()
|
||||
|
||||
def opensite(self, url):
|
||||
import webbrowser
|
||||
webbrowser.open(url, new=2)
|
||||
|
||||
def titleIp(self):
|
||||
title = "!IP: %s" % config.ip_external
|
||||
if self.main.file_server.port_opened:
|
||||
title += " (active)"
|
||||
else:
|
||||
title += " (passive)"
|
||||
return title
|
||||
|
||||
def titleConnections(self):
|
||||
title = "Connections: %s" % len(self.main.file_server.connections)
|
||||
return title
|
||||
|
||||
def titleTransfer(self):
|
||||
title = "Received: %.2f MB | Sent: %.2f MB" % (float(self.main.file_server.bytes_recv)/1024/1024, float(self.main.file_server.bytes_sent)/1024/1024)
|
||||
return title
|
||||
|
||||
def titleConsole(self):
|
||||
if self.console: return "+Show console window"
|
||||
else: return "Show console window"
|
||||
|
||||
def toggleConsole(self):
|
||||
if self.console:
|
||||
notificationicon.hideConsole()
|
||||
self.console = False
|
||||
else:
|
||||
notificationicon.showConsole()
|
||||
self.console = True
|
4
plugins/Trayicon/__init__.py
Normal file
4
plugins/Trayicon/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
import sys
|
||||
|
||||
if sys.platform == 'win32':
|
||||
import TrayiconPlugin
|
0
plugins/Trayicon/lib/__init__.py
Normal file
0
plugins/Trayicon/lib/__init__.py
Normal file
800
plugins/Trayicon/lib/notificationicon.py
Normal file
800
plugins/Trayicon/lib/notificationicon.py
Normal file
|
@ -0,0 +1,800 @@
|
|||
# Pure ctypes windows taskbar notification icon
|
||||
# via https://gist.github.com/jasonbot/5759510
|
||||
# Modified for ZeroNet
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
import os
|
||||
#import threading
|
||||
#import Queue
|
||||
import uuid
|
||||
import time
|
||||
import gevent
|
||||
|
||||
__all__ = ['NotificationIcon']
|
||||
|
||||
# Create popup menu
|
||||
|
||||
CreatePopupMenu = ctypes.windll.user32.CreatePopupMenu
|
||||
CreatePopupMenu.restype = ctypes.wintypes.HMENU
|
||||
CreatePopupMenu.argtypes = []
|
||||
|
||||
MF_BYCOMMAND = 0x0
|
||||
MF_BYPOSITION = 0x400
|
||||
|
||||
MF_BITMAP = 0x4
|
||||
MF_CHECKED = 0x8
|
||||
MF_DISABLED = 0x2
|
||||
MF_ENABLED = 0x0
|
||||
MF_GRAYED = 0x1
|
||||
MF_MENUBARBREAK = 0x20
|
||||
MF_MENUBREAK = 0x40
|
||||
MF_OWNERDRAW = 0x100
|
||||
MF_POPUP = 0x10
|
||||
MF_SEPARATOR = 0x800
|
||||
MF_STRING = 0x0
|
||||
MF_UNCHECKED = 0x0
|
||||
|
||||
InsertMenu = ctypes.windll.user32.InsertMenuW
|
||||
InsertMenu.restype = ctypes.wintypes.BOOL
|
||||
InsertMenu.argtypes = [ctypes.wintypes.HMENU, ctypes.wintypes.UINT, ctypes.wintypes.UINT, ctypes.wintypes.UINT, ctypes.wintypes.LPCWSTR]
|
||||
|
||||
AppendMenu = ctypes.windll.user32.AppendMenuW
|
||||
AppendMenu.restype = ctypes.wintypes.BOOL
|
||||
AppendMenu.argtypes = [ctypes.wintypes.HMENU, ctypes.wintypes.UINT, ctypes.wintypes.UINT, ctypes.wintypes.LPCWSTR]
|
||||
|
||||
SetMenuDefaultItem = ctypes.windll.user32.SetMenuDefaultItem
|
||||
SetMenuDefaultItem.restype = ctypes.wintypes.BOOL
|
||||
SetMenuDefaultItem.argtypes = [ctypes.wintypes.HMENU, ctypes.wintypes.UINT, ctypes.wintypes.UINT]
|
||||
|
||||
#class MENUITEMINFO(ctypes.Structure):
|
||||
# UINT cbSize;
|
||||
# UINT fMask;
|
||||
# UINT fType;
|
||||
# UINT fState;
|
||||
# UINT wID;
|
||||
# HMENU hSubMenu;
|
||||
# HBITMAP hbmpChecked;
|
||||
# HBITMAP hbmpUnchecked;
|
||||
# ULONG_PTR dwItemData;
|
||||
# LPTSTR dwTypeData;
|
||||
# UINT cch;
|
||||
# HBITMAP hbmpItem;
|
||||
#
|
||||
#BOOL WINAPI InsertMenuItem(
|
||||
# __in HMENU hMenu,
|
||||
# __in UINT uItem,
|
||||
# __in BOOL fByPosition,
|
||||
# __in LPCMENUITEMINFO lpmii
|
||||
#);
|
||||
#
|
||||
|
||||
class POINT(ctypes.Structure):
|
||||
_fields_ = [ ('x', ctypes.wintypes.LONG),
|
||||
('y', ctypes.wintypes.LONG)]
|
||||
|
||||
GetCursorPos = ctypes.windll.user32.GetCursorPos
|
||||
GetCursorPos.argtypes = [ctypes.POINTER(POINT)]
|
||||
|
||||
SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow
|
||||
SetForegroundWindow.argtypes = [ctypes.wintypes.HWND]
|
||||
|
||||
TPM_LEFTALIGN = 0x0
|
||||
TPM_CENTERALIGN = 0x4
|
||||
TPM_RIGHTALIGN = 0x8
|
||||
|
||||
TPM_TOPALIGN = 0x0
|
||||
TPM_VCENTERALIGN = 0x10
|
||||
TPM_BOTTOMALIGN = 0x20
|
||||
|
||||
TPM_NONOTIFY = 0x80
|
||||
TPM_RETURNCMD = 0x100
|
||||
|
||||
TPM_LEFTBUTTON = 0x0
|
||||
TPM_RIGHTBUTTON = 0x2
|
||||
|
||||
TPM_HORNEGANIMATION = 0x800
|
||||
TPM_HORPOSANIMATION = 0x400
|
||||
TPM_NOANIMATION = 0x4000
|
||||
TPM_VERNEGANIMATION = 0x2000
|
||||
TPM_VERPOSANIMATION = 0x1000
|
||||
|
||||
TrackPopupMenu = ctypes.windll.user32.TrackPopupMenu
|
||||
TrackPopupMenu.restype = ctypes.wintypes.BOOL
|
||||
TrackPopupMenu.argtypes = [ctypes.wintypes.HMENU, ctypes.wintypes.UINT, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.wintypes.HWND, ctypes.c_void_p]
|
||||
|
||||
PostMessage = ctypes.windll.user32.PostMessageW
|
||||
PostMessage.restype = ctypes.wintypes.BOOL
|
||||
PostMessage.argtypes = [ctypes.wintypes.HWND, ctypes.wintypes.UINT, ctypes.wintypes.WPARAM, ctypes.wintypes.LPARAM]
|
||||
|
||||
DestroyMenu = ctypes.windll.user32.DestroyMenu
|
||||
DestroyMenu.restype = ctypes.wintypes.BOOL
|
||||
DestroyMenu.argtypes = [ctypes.wintypes.HMENU]
|
||||
|
||||
# Create notification icon
|
||||
|
||||
GUID = ctypes.c_ubyte * 16
|
||||
|
||||
class TimeoutVersionUnion(ctypes.Union):
|
||||
_fields_ = [('uTimeout', ctypes.wintypes.UINT),
|
||||
('uVersion', ctypes.wintypes.UINT),]
|
||||
|
||||
NIS_HIDDEN = 0x1
|
||||
NIS_SHAREDICON = 0x2
|
||||
|
||||
class NOTIFYICONDATA(ctypes.Structure):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(NOTIFYICONDATA, self).__init__(*args, **kwargs)
|
||||
self.cbSize = ctypes.sizeof(self)
|
||||
_fields_ = [
|
||||
('cbSize', ctypes.wintypes.DWORD),
|
||||
('hWnd', ctypes.wintypes.HWND),
|
||||
('uID', ctypes.wintypes.UINT),
|
||||
('uFlags', ctypes.wintypes.UINT),
|
||||
('uCallbackMessage', ctypes.wintypes.UINT),
|
||||
('hIcon', ctypes.wintypes.HICON),
|
||||
('szTip', ctypes.wintypes.WCHAR * 64),
|
||||
('dwState', ctypes.wintypes.DWORD),
|
||||
('dwStateMask', ctypes.wintypes.DWORD),
|
||||
('szInfo', ctypes.wintypes.WCHAR * 256),
|
||||
('union', TimeoutVersionUnion),
|
||||
('szInfoTitle', ctypes.wintypes.WCHAR * 64),
|
||||
('dwInfoFlags', ctypes.wintypes.DWORD),
|
||||
('guidItem', GUID),
|
||||
('hBalloonIcon', ctypes.wintypes.HICON),
|
||||
]
|
||||
|
||||
NIM_ADD = 0
|
||||
NIM_MODIFY = 1
|
||||
NIM_DELETE = 2
|
||||
NIM_SETFOCUS = 3
|
||||
NIM_SETVERSION = 4
|
||||
|
||||
NIF_MESSAGE = 1
|
||||
NIF_ICON = 2
|
||||
NIF_TIP = 4
|
||||
NIF_STATE = 8
|
||||
NIF_INFO = 16
|
||||
NIF_GUID = 32
|
||||
NIF_REALTIME = 64
|
||||
NIF_SHOWTIP = 128
|
||||
|
||||
NIIF_NONE = 0
|
||||
NIIF_INFO = 1
|
||||
NIIF_WARNING = 2
|
||||
NIIF_ERROR = 3
|
||||
NIIF_USER = 4
|
||||
|
||||
NOTIFYICON_VERSION = 3
|
||||
NOTIFYICON_VERSION_4 = 4
|
||||
|
||||
Shell_NotifyIcon = ctypes.windll.shell32.Shell_NotifyIconW
|
||||
Shell_NotifyIcon.restype = ctypes.wintypes.BOOL
|
||||
Shell_NotifyIcon.argtypes = [ctypes.wintypes.DWORD, ctypes.POINTER(NOTIFYICONDATA)]
|
||||
|
||||
# Load icon/image
|
||||
|
||||
IMAGE_BITMAP = 0
|
||||
IMAGE_ICON = 1
|
||||
IMAGE_CURSOR = 2
|
||||
|
||||
LR_CREATEDIBSECTION = 0x00002000
|
||||
LR_DEFAULTCOLOR = 0x00000000
|
||||
LR_DEFAULTSIZE = 0x00000040
|
||||
LR_LOADFROMFILE = 0x00000010
|
||||
LR_LOADMAP3DCOLORS = 0x00001000
|
||||
LR_LOADTRANSPARENT = 0x00000020
|
||||
LR_MONOCHROME = 0x00000001
|
||||
LR_SHARED = 0x00008000
|
||||
LR_VGACOLOR = 0x00000080
|
||||
|
||||
OIC_SAMPLE = 32512
|
||||
OIC_HAND = 32513
|
||||
OIC_QUES = 32514
|
||||
OIC_BANG = 32515
|
||||
OIC_NOTE = 32516
|
||||
OIC_WINLOGO = 32517
|
||||
OIC_WARNING = OIC_BANG
|
||||
OIC_ERROR = OIC_HAND
|
||||
OIC_INFORMATION = OIC_NOTE
|
||||
|
||||
LoadImage = ctypes.windll.user32.LoadImageW
|
||||
LoadImage.restype = ctypes.wintypes.HANDLE
|
||||
LoadImage.argtypes = [ctypes.wintypes.HINSTANCE, ctypes.wintypes.LPCWSTR, ctypes.wintypes.UINT, ctypes.c_int, ctypes.c_int, ctypes.wintypes.UINT]
|
||||
|
||||
# CreateWindow call
|
||||
|
||||
WNDPROC = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.wintypes.HWND, ctypes.c_uint, ctypes.wintypes.WPARAM, ctypes.wintypes.LPARAM)
|
||||
DefWindowProc = ctypes.windll.user32.DefWindowProcW
|
||||
DefWindowProc.restype = ctypes.c_int
|
||||
DefWindowProc.argtypes = [ctypes.wintypes.HWND, ctypes.c_uint, ctypes.wintypes.WPARAM, ctypes.wintypes.LPARAM]
|
||||
|
||||
WS_OVERLAPPED = 0x00000000L
|
||||
WS_POPUP = 0x80000000L
|
||||
WS_CHILD = 0x40000000L
|
||||
WS_MINIMIZE = 0x20000000L
|
||||
WS_VISIBLE = 0x10000000L
|
||||
WS_DISABLED = 0x08000000L
|
||||
WS_CLIPSIBLINGS = 0x04000000L
|
||||
WS_CLIPCHILDREN = 0x02000000L
|
||||
WS_MAXIMIZE = 0x01000000L
|
||||
WS_CAPTION = 0x00C00000L
|
||||
WS_BORDER = 0x00800000L
|
||||
WS_DLGFRAME = 0x00400000L
|
||||
WS_VSCROLL = 0x00200000L
|
||||
WS_HSCROLL = 0x00100000L
|
||||
WS_SYSMENU = 0x00080000L
|
||||
WS_THICKFRAME = 0x00040000L
|
||||
WS_GROUP = 0x00020000L
|
||||
WS_TABSTOP = 0x00010000L
|
||||
|
||||
WS_MINIMIZEBOX = 0x00020000L
|
||||
WS_MAXIMIZEBOX = 0x00010000L
|
||||
|
||||
WS_OVERLAPPEDWINDOW = (WS_OVERLAPPED |
|
||||
WS_CAPTION |
|
||||
WS_SYSMENU |
|
||||
WS_THICKFRAME |
|
||||
WS_MINIMIZEBOX |
|
||||
WS_MAXIMIZEBOX)
|
||||
|
||||
SM_XVIRTUALSCREEN = 76
|
||||
SM_YVIRTUALSCREEN = 77
|
||||
SM_CXVIRTUALSCREEN = 78
|
||||
SM_CYVIRTUALSCREEN = 79
|
||||
SM_CMONITORS = 80
|
||||
SM_SAMEDISPLAYFORMAT = 81
|
||||
|
||||
WM_NULL = 0x0000
|
||||
WM_CREATE = 0x0001
|
||||
WM_DESTROY = 0x0002
|
||||
WM_MOVE = 0x0003
|
||||
WM_SIZE = 0x0005
|
||||
WM_ACTIVATE = 0x0006
|
||||
WM_SETFOCUS = 0x0007
|
||||
WM_KILLFOCUS = 0x0008
|
||||
WM_ENABLE = 0x000A
|
||||
WM_SETREDRAW = 0x000B
|
||||
WM_SETTEXT = 0x000C
|
||||
WM_GETTEXT = 0x000D
|
||||
WM_GETTEXTLENGTH = 0x000E
|
||||
WM_PAINT = 0x000F
|
||||
WM_CLOSE = 0x0010
|
||||
WM_QUERYENDSESSION = 0x0011
|
||||
WM_QUIT = 0x0012
|
||||
WM_QUERYOPEN = 0x0013
|
||||
WM_ERASEBKGND = 0x0014
|
||||
WM_SYSCOLORCHANGE = 0x0015
|
||||
WM_ENDSESSION = 0x0016
|
||||
WM_SHOWWINDOW = 0x0018
|
||||
WM_CTLCOLOR = 0x0019
|
||||
WM_WININICHANGE = 0x001A
|
||||
WM_SETTINGCHANGE = 0x001A
|
||||
WM_DEVMODECHANGE = 0x001B
|
||||
WM_ACTIVATEAPP = 0x001C
|
||||
WM_FONTCHANGE = 0x001D
|
||||
WM_TIMECHANGE = 0x001E
|
||||
WM_CANCELMODE = 0x001F
|
||||
WM_SETCURSOR = 0x0020
|
||||
WM_MOUSEACTIVATE = 0x0021
|
||||
WM_CHILDACTIVATE = 0x0022
|
||||
WM_QUEUESYNC = 0x0023
|
||||
WM_GETMINMAXINFO = 0x0024
|
||||
WM_PAINTICON = 0x0026
|
||||
WM_ICONERASEBKGND = 0x0027
|
||||
WM_NEXTDLGCTL = 0x0028
|
||||
WM_SPOOLERSTATUS = 0x002A
|
||||
WM_DRAWITEM = 0x002B
|
||||
WM_MEASUREITEM = 0x002C
|
||||
WM_DELETEITEM = 0x002D
|
||||
WM_VKEYTOITEM = 0x002E
|
||||
WM_CHARTOITEM = 0x002F
|
||||
WM_SETFONT = 0x0030
|
||||
WM_GETFONT = 0x0031
|
||||
WM_SETHOTKEY = 0x0032
|
||||
WM_GETHOTKEY = 0x0033
|
||||
WM_QUERYDRAGICON = 0x0037
|
||||
WM_COMPAREITEM = 0x0039
|
||||
WM_GETOBJECT = 0x003D
|
||||
WM_COMPACTING = 0x0041
|
||||
WM_COMMNOTIFY = 0x0044
|
||||
WM_WINDOWPOSCHANGING = 0x0046
|
||||
WM_WINDOWPOSCHANGED = 0x0047
|
||||
WM_POWER = 0x0048
|
||||
WM_COPYDATA = 0x004A
|
||||
WM_CANCELJOURNAL = 0x004B
|
||||
WM_NOTIFY = 0x004E
|
||||
WM_INPUTLANGCHANGEREQUEST = 0x0050
|
||||
WM_INPUTLANGCHANGE = 0x0051
|
||||
WM_TCARD = 0x0052
|
||||
WM_HELP = 0x0053
|
||||
WM_USERCHANGED = 0x0054
|
||||
WM_NOTIFYFORMAT = 0x0055
|
||||
WM_CONTEXTMENU = 0x007B
|
||||
WM_STYLECHANGING = 0x007C
|
||||
WM_STYLECHANGED = 0x007D
|
||||
WM_DISPLAYCHANGE = 0x007E
|
||||
WM_GETICON = 0x007F
|
||||
WM_SETICON = 0x0080
|
||||
WM_NCCREATE = 0x0081
|
||||
WM_NCDESTROY = 0x0082
|
||||
WM_NCCALCSIZE = 0x0083
|
||||
WM_NCHITTEST = 0x0084
|
||||
WM_NCPAINT = 0x0085
|
||||
WM_NCACTIVATE = 0x0086
|
||||
WM_GETDLGCODE = 0x0087
|
||||
WM_SYNCPAINT = 0x0088
|
||||
WM_NCMOUSEMOVE = 0x00A0
|
||||
WM_NCLBUTTONDOWN = 0x00A1
|
||||
WM_NCLBUTTONUP = 0x00A2
|
||||
WM_NCLBUTTONDBLCLK = 0x00A3
|
||||
WM_NCRBUTTONDOWN = 0x00A4
|
||||
WM_NCRBUTTONUP = 0x00A5
|
||||
WM_NCRBUTTONDBLCLK = 0x00A6
|
||||
WM_NCMBUTTONDOWN = 0x00A7
|
||||
WM_NCMBUTTONUP = 0x00A8
|
||||
WM_NCMBUTTONDBLCLK = 0x00A9
|
||||
WM_KEYDOWN = 0x0100
|
||||
WM_KEYUP = 0x0101
|
||||
WM_CHAR = 0x0102
|
||||
WM_DEADCHAR = 0x0103
|
||||
WM_SYSKEYDOWN = 0x0104
|
||||
WM_SYSKEYUP = 0x0105
|
||||
WM_SYSCHAR = 0x0106
|
||||
WM_SYSDEADCHAR = 0x0107
|
||||
WM_KEYLAST = 0x0108
|
||||
WM_IME_STARTCOMPOSITION = 0x010D
|
||||
WM_IME_ENDCOMPOSITION = 0x010E
|
||||
WM_IME_COMPOSITION = 0x010F
|
||||
WM_IME_KEYLAST = 0x010F
|
||||
WM_INITDIALOG = 0x0110
|
||||
WM_COMMAND = 0x0111
|
||||
WM_SYSCOMMAND = 0x0112
|
||||
WM_TIMER = 0x0113
|
||||
WM_HSCROLL = 0x0114
|
||||
WM_VSCROLL = 0x0115
|
||||
WM_INITMENU = 0x0116
|
||||
WM_INITMENUPOPUP = 0x0117
|
||||
WM_MENUSELECT = 0x011F
|
||||
WM_MENUCHAR = 0x0120
|
||||
WM_ENTERIDLE = 0x0121
|
||||
WM_MENURBUTTONUP = 0x0122
|
||||
WM_MENUDRAG = 0x0123
|
||||
WM_MENUGETOBJECT = 0x0124
|
||||
WM_UNINITMENUPOPUP = 0x0125
|
||||
WM_MENUCOMMAND = 0x0126
|
||||
WM_CTLCOLORMSGBOX = 0x0132
|
||||
WM_CTLCOLOREDIT = 0x0133
|
||||
WM_CTLCOLORLISTBOX = 0x0134
|
||||
WM_CTLCOLORBTN = 0x0135
|
||||
WM_CTLCOLORDLG = 0x0136
|
||||
WM_CTLCOLORSCROLLBAR = 0x0137
|
||||
WM_CTLCOLORSTATIC = 0x0138
|
||||
WM_MOUSEMOVE = 0x0200
|
||||
WM_LBUTTONDOWN = 0x0201
|
||||
WM_LBUTTONUP = 0x0202
|
||||
WM_LBUTTONDBLCLK = 0x0203
|
||||
WM_RBUTTONDOWN = 0x0204
|
||||
WM_RBUTTONUP = 0x0205
|
||||
WM_RBUTTONDBLCLK = 0x0206
|
||||
WM_MBUTTONDOWN = 0x0207
|
||||
WM_MBUTTONUP = 0x0208
|
||||
WM_MBUTTONDBLCLK = 0x0209
|
||||
WM_MOUSEWHEEL = 0x020A
|
||||
WM_PARENTNOTIFY = 0x0210
|
||||
WM_ENTERMENULOOP = 0x0211
|
||||
WM_EXITMENULOOP = 0x0212
|
||||
WM_NEXTMENU = 0x0213
|
||||
WM_SIZING = 0x0214
|
||||
WM_CAPTURECHANGED = 0x0215
|
||||
WM_MOVING = 0x0216
|
||||
WM_DEVICECHANGE = 0x0219
|
||||
WM_MDICREATE = 0x0220
|
||||
WM_MDIDESTROY = 0x0221
|
||||
WM_MDIACTIVATE = 0x0222
|
||||
WM_MDIRESTORE = 0x0223
|
||||
WM_MDINEXT = 0x0224
|
||||
WM_MDIMAXIMIZE = 0x0225
|
||||
WM_MDITILE = 0x0226
|
||||
WM_MDICASCADE = 0x0227
|
||||
WM_MDIICONARRANGE = 0x0228
|
||||
WM_MDIGETACTIVE = 0x0229
|
||||
WM_MDISETMENU = 0x0230
|
||||
WM_ENTERSIZEMOVE = 0x0231
|
||||
WM_EXITSIZEMOVE = 0x0232
|
||||
WM_DROPFILES = 0x0233
|
||||
WM_MDIREFRESHMENU = 0x0234
|
||||
WM_IME_SETCONTEXT = 0x0281
|
||||
WM_IME_NOTIFY = 0x0282
|
||||
WM_IME_CONTROL = 0x0283
|
||||
WM_IME_COMPOSITIONFULL = 0x0284
|
||||
WM_IME_SELECT = 0x0285
|
||||
WM_IME_CHAR = 0x0286
|
||||
WM_IME_REQUEST = 0x0288
|
||||
WM_IME_KEYDOWN = 0x0290
|
||||
WM_IME_KEYUP = 0x0291
|
||||
WM_MOUSEHOVER = 0x02A1
|
||||
WM_MOUSELEAVE = 0x02A3
|
||||
WM_CUT = 0x0300
|
||||
WM_COPY = 0x0301
|
||||
WM_PASTE = 0x0302
|
||||
WM_CLEAR = 0x0303
|
||||
WM_UNDO = 0x0304
|
||||
WM_RENDERFORMAT = 0x0305
|
||||
WM_RENDERALLFORMATS = 0x0306
|
||||
WM_DESTROYCLIPBOARD = 0x0307
|
||||
WM_DRAWCLIPBOARD = 0x0308
|
||||
WM_PAINTCLIPBOARD = 0x0309
|
||||
WM_VSCROLLCLIPBOARD = 0x030A
|
||||
WM_SIZECLIPBOARD = 0x030B
|
||||
WM_ASKCBFORMATNAME = 0x030C
|
||||
WM_CHANGECBCHAIN = 0x030D
|
||||
WM_HSCROLLCLIPBOARD = 0x030E
|
||||
WM_QUERYNEWPALETTE = 0x030F
|
||||
WM_PALETTEISCHANGING = 0x0310
|
||||
WM_PALETTECHANGED = 0x0311
|
||||
WM_HOTKEY = 0x0312
|
||||
WM_PRINT = 0x0317
|
||||
WM_PRINTCLIENT = 0x0318
|
||||
WM_HANDHELDFIRST = 0x0358
|
||||
WM_HANDHELDLAST = 0x035F
|
||||
WM_AFXFIRST = 0x0360
|
||||
WM_AFXLAST = 0x037F
|
||||
WM_PENWINFIRST = 0x0380
|
||||
WM_PENWINLAST = 0x038F
|
||||
WM_APP = 0x8000
|
||||
WM_USER = 0x0400
|
||||
WM_REFLECT = WM_USER + 0x1c00
|
||||
|
||||
class WNDCLASSEX(ctypes.Structure):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(WNDCLASSEX, self).__init__(*args, **kwargs)
|
||||
self.cbSize = ctypes.sizeof(self)
|
||||
_fields_ = [("cbSize", ctypes.c_uint),
|
||||
("style", ctypes.c_uint),
|
||||
("lpfnWndProc", WNDPROC),
|
||||
("cbClsExtra", ctypes.c_int),
|
||||
("cbWndExtra", ctypes.c_int),
|
||||
("hInstance", ctypes.wintypes.HANDLE),
|
||||
("hIcon", ctypes.wintypes.HANDLE),
|
||||
("hCursor", ctypes.wintypes.HANDLE),
|
||||
("hBrush", ctypes.wintypes.HANDLE),
|
||||
("lpszMenuName", ctypes.wintypes.LPCWSTR),
|
||||
("lpszClassName", ctypes.wintypes.LPCWSTR),
|
||||
("hIconSm", ctypes.wintypes.HANDLE)]
|
||||
|
||||
UpdateWindow = ctypes.windll.user32.UpdateWindow
|
||||
UpdateWindow.argtypes = [ctypes.wintypes.HWND]
|
||||
|
||||
SW_HIDE = 0
|
||||
SW_SHOWNORMAL = 1
|
||||
SW_SHOW = 5
|
||||
|
||||
ShowWindow = ctypes.windll.user32.ShowWindow
|
||||
ShowWindow.argtypes = [ctypes.wintypes.HWND, ctypes.c_int]
|
||||
|
||||
CS_VREDRAW = 0x0001
|
||||
CS_HREDRAW = 0x0002
|
||||
CS_KEYCVTWINDOW = 0x0004
|
||||
CS_DBLCLKS = 0x0008
|
||||
CS_OWNDC = 0x0020
|
||||
CS_CLASSDC = 0x0040
|
||||
CS_PARENTDC = 0x0080
|
||||
CS_NOKEYCVT = 0x0100
|
||||
CS_NOCLOSE = 0x0200
|
||||
CS_SAVEBITS = 0x0800
|
||||
CS_BYTEALIGNCLIENT = 0x1000
|
||||
CS_BYTEALIGNWINDOW = 0x2000
|
||||
CS_GLOBALCLASS = 0x4000
|
||||
|
||||
COLOR_SCROLLBAR = 0
|
||||
COLOR_BACKGROUND = 1
|
||||
COLOR_ACTIVECAPTION = 2
|
||||
COLOR_INACTIVECAPTION = 3
|
||||
COLOR_MENU = 4
|
||||
COLOR_WINDOW = 5
|
||||
COLOR_WINDOWFRAME = 6
|
||||
COLOR_MENUTEXT = 7
|
||||
COLOR_WINDOWTEXT = 8
|
||||
COLOR_CAPTIONTEXT = 9
|
||||
COLOR_ACTIVEBORDER = 10
|
||||
COLOR_INACTIVEBORDER = 11
|
||||
COLOR_APPWORKSPACE = 12
|
||||
COLOR_HIGHLIGHT = 13
|
||||
COLOR_HIGHLIGHTTEXT = 14
|
||||
COLOR_BTNFACE = 15
|
||||
COLOR_BTNSHADOW = 16
|
||||
COLOR_GRAYTEXT = 17
|
||||
COLOR_BTNTEXT = 18
|
||||
COLOR_INACTIVECAPTIONTEXT = 19
|
||||
COLOR_BTNHIGHLIGHT = 20
|
||||
|
||||
LoadCursor = ctypes.windll.user32.LoadCursorW
|
||||
|
||||
def GenerateDummyWindow(callback, uid):
|
||||
newclass = WNDCLASSEX()
|
||||
newclass.lpfnWndProc = callback
|
||||
newclass.style = CS_VREDRAW | CS_HREDRAW
|
||||
newclass.lpszClassName = uid.replace("-", "")
|
||||
newclass.hBrush = COLOR_BACKGROUND
|
||||
newclass.hCursor = LoadCursor(0, 32512)
|
||||
ATOM = ctypes.windll.user32.RegisterClassExW(ctypes.byref(newclass))
|
||||
#print "ATOM", ATOM
|
||||
#print "CLASS", newclass.lpszClassName
|
||||
hwnd = ctypes.windll.user32.CreateWindowExW(0,
|
||||
newclass.lpszClassName,
|
||||
u"Dummy Window",
|
||||
WS_OVERLAPPEDWINDOW | WS_SYSMENU,
|
||||
ctypes.windll.user32.GetSystemMetrics(SM_CXVIRTUALSCREEN),
|
||||
ctypes.windll.user32.GetSystemMetrics(SM_CYVIRTUALSCREEN),
|
||||
800, 600, 0, 0, 0, 0)
|
||||
ShowWindow(hwnd, SW_SHOW)
|
||||
UpdateWindow(hwnd)
|
||||
ShowWindow(hwnd, SW_HIDE)
|
||||
return hwnd
|
||||
|
||||
# Message loop calls
|
||||
|
||||
TIMERCALLBACK = ctypes.WINFUNCTYPE(None,
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.wintypes.UINT,
|
||||
ctypes.POINTER(ctypes.wintypes.UINT),
|
||||
ctypes.wintypes.DWORD)
|
||||
|
||||
SetTimer = ctypes.windll.user32.SetTimer
|
||||
SetTimer.restype = ctypes.POINTER(ctypes.wintypes.UINT)
|
||||
SetTimer.argtypes = [ctypes.wintypes.HWND,
|
||||
ctypes.POINTER(ctypes.wintypes.UINT),
|
||||
ctypes.wintypes.UINT,
|
||||
TIMERCALLBACK]
|
||||
|
||||
KillTimer = ctypes.windll.user32.KillTimer
|
||||
KillTimer.restype = ctypes.wintypes.BOOL
|
||||
KillTimer.argtypes = [ctypes.wintypes.HWND,
|
||||
ctypes.POINTER(ctypes.wintypes.UINT)]
|
||||
|
||||
class MSG(ctypes.Structure):
|
||||
_fields_ = [ ('HWND', ctypes.wintypes.HWND),
|
||||
('message', ctypes.wintypes.UINT),
|
||||
('wParam', ctypes.wintypes.WPARAM),
|
||||
('lParam', ctypes.wintypes.LPARAM),
|
||||
('time', ctypes.wintypes.DWORD),
|
||||
('pt', POINT)]
|
||||
|
||||
GetMessage = ctypes.windll.user32.GetMessageW
|
||||
GetMessage.restype = ctypes.wintypes.BOOL
|
||||
GetMessage.argtypes = [ctypes.POINTER(MSG), ctypes.wintypes.HWND, ctypes.wintypes.UINT, ctypes.wintypes.UINT]
|
||||
|
||||
TranslateMessage = ctypes.windll.user32.TranslateMessage
|
||||
TranslateMessage.restype = ctypes.wintypes.ULONG
|
||||
TranslateMessage.argtypes = [ctypes.POINTER(MSG)]
|
||||
|
||||
DispatchMessage = ctypes.windll.user32.DispatchMessageW
|
||||
DispatchMessage.restype = ctypes.wintypes.ULONG
|
||||
DispatchMessage.argtypes = [ctypes.POINTER(MSG)]
|
||||
|
||||
def LoadIcon(iconfilename, small=False):
|
||||
return LoadImage(0,
|
||||
unicode(iconfilename),
|
||||
IMAGE_ICON,
|
||||
16 if small else 0,
|
||||
16 if small else 0,
|
||||
LR_LOADFROMFILE)
|
||||
|
||||
|
||||
class NotificationIcon(object):
|
||||
def __init__(self, iconfilename, tooltip=None):
|
||||
assert os.path.isfile(unicode(iconfilename)), "{} doesn't exist".format(iconfilename)
|
||||
self._iconfile = unicode(iconfilename)
|
||||
self._hicon = LoadIcon(self._iconfile, True)
|
||||
assert self._hicon, "Failed to load {}".format(iconfilename)
|
||||
#self._pumpqueue = Queue.Queue()
|
||||
self._die = False
|
||||
self._timerid = None
|
||||
self._uid = uuid.uuid4()
|
||||
self._tooltip = unicode(tooltip) if tooltip else u''
|
||||
#self._thread = threading.Thread(target=self._run)
|
||||
#self._thread.start()
|
||||
self._info_bubble = None
|
||||
self.items = []
|
||||
|
||||
|
||||
def _bubble(self, iconinfo):
|
||||
if self._info_bubble:
|
||||
info_bubble = self._info_bubble
|
||||
self._info_bubble = None
|
||||
message = unicode(self._info_bubble)
|
||||
iconinfo.uFlags |= NIF_INFO
|
||||
iconinfo.szInfo = message
|
||||
iconinfo.szInfoTitle = message
|
||||
iconinfo.dwInfoFlags = NIIF_INFO
|
||||
iconinfo.union.uTimeout = 10000
|
||||
Shell_NotifyIcon(NIM_MODIFY, ctypes.pointer(iconinfo))
|
||||
|
||||
|
||||
def _run(self):
|
||||
self._windowproc = WNDPROC(self._callback)
|
||||
self._hwnd = GenerateDummyWindow(self._windowproc, str(self._uid))
|
||||
|
||||
iconinfo = NOTIFYICONDATA()
|
||||
iconinfo.hWnd = self._hwnd
|
||||
iconinfo.uID = 100
|
||||
iconinfo.uFlags = NIF_ICON | NIF_SHOWTIP | NIF_MESSAGE | (NIF_TIP if self._tooltip else 0)
|
||||
iconinfo.uCallbackMessage = WM_MENUCOMMAND
|
||||
iconinfo.hIcon = self._hicon
|
||||
iconinfo.szTip = self._tooltip
|
||||
iconinfo.dwState = NIS_SHAREDICON
|
||||
iconinfo.dwInfoFlags = NIIF_INFO
|
||||
# iconinfo.dwStateMask = NIS_SHAREDICON
|
||||
iconinfo.szInfo = "Application Title"
|
||||
iconinfo.union.uTimeout = 5000
|
||||
|
||||
Shell_NotifyIcon(NIM_ADD, ctypes.pointer(iconinfo))
|
||||
|
||||
iconinfo.union.uVersion = NOTIFYICON_VERSION
|
||||
self.iconinfo = iconinfo
|
||||
|
||||
Shell_NotifyIcon(NIM_SETVERSION, ctypes.pointer(iconinfo))
|
||||
|
||||
PostMessage(self._hwnd, WM_NULL, 0, 0)
|
||||
|
||||
#self._timerid = SetTimer(self._hwnd, self._timerid, 25, TIMERCALLBACK())
|
||||
message = MSG()
|
||||
last_time = -1
|
||||
ret = None
|
||||
while not self._die:
|
||||
ret = GetMessage(ctypes.pointer(message), 0, 0, 0)
|
||||
TranslateMessage(ctypes.pointer(message))
|
||||
DispatchMessage(ctypes.pointer(message))
|
||||
time.sleep(0.125)
|
||||
#KillTimer(self._hwnd, self._timerid)
|
||||
|
||||
Shell_NotifyIcon(NIM_DELETE, ctypes.pointer(iconinfo))
|
||||
ctypes.windll.user32.DestroyWindow(self._hwnd)
|
||||
ctypes.windll.user32.DestroyIcon(self._hicon)
|
||||
|
||||
|
||||
def _menu(self):
|
||||
if not hasattr(self, 'items'):
|
||||
return
|
||||
|
||||
menu = CreatePopupMenu()
|
||||
func = None
|
||||
|
||||
try:
|
||||
iidx = 1000
|
||||
defaultitem = -1
|
||||
item_map = {}
|
||||
for fs in self.items:
|
||||
iidx += 1
|
||||
if isinstance(fs, basestring):
|
||||
if fs and not fs.strip('-_='):
|
||||
AppendMenu(menu, MF_SEPARATOR, iidx, fs)
|
||||
else:
|
||||
AppendMenu(menu, MF_STRING | MF_GRAYED, iidx, fs)
|
||||
elif isinstance(fs, tuple):
|
||||
if callable(fs[0]):
|
||||
itemstring = fs[0]()
|
||||
else:
|
||||
itemstring = unicode(fs[0])
|
||||
flags = MF_STRING
|
||||
if itemstring.startswith("!"):
|
||||
itemstring = itemstring[1:]
|
||||
defaultitem = iidx
|
||||
if itemstring.startswith("+"):
|
||||
itemstring = itemstring[1:]
|
||||
flags = flags | MF_CHECKED
|
||||
itemcallable = fs[1]
|
||||
item_map[iidx] = itemcallable
|
||||
if itemcallable is False:
|
||||
flags = flags | MF_DISABLED
|
||||
elif not callable(itemcallable):
|
||||
flags = flags | MF_GRAYED
|
||||
AppendMenu(menu, flags, iidx, itemstring)
|
||||
|
||||
if defaultitem != -1:
|
||||
SetMenuDefaultItem(menu, defaultitem, 0)
|
||||
|
||||
pos = POINT()
|
||||
GetCursorPos(ctypes.pointer(pos))
|
||||
|
||||
PostMessage(self._hwnd, WM_NULL, 0, 0)
|
||||
|
||||
SetForegroundWindow(self._hwnd)
|
||||
|
||||
ti = TrackPopupMenu(menu, TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY, pos.x, pos.y, 0, self._hwnd, None)
|
||||
|
||||
if ti in item_map:
|
||||
func = item_map[ti]
|
||||
|
||||
PostMessage(self._hwnd, WM_NULL, 0, 0)
|
||||
finally:
|
||||
DestroyMenu(menu)
|
||||
if func: func()
|
||||
|
||||
|
||||
def clicked(self):
|
||||
self._menu()
|
||||
|
||||
|
||||
|
||||
def _callback(self, hWnd, msg, wParam, lParam):
|
||||
# Check if the main thread is still alive
|
||||
if msg == WM_TIMER:
|
||||
if not any(thread.getName() == 'MainThread' and thread.isAlive()
|
||||
for thread in threading.enumerate()):
|
||||
self._die = True
|
||||
elif msg == WM_MENUCOMMAND and lParam == WM_LBUTTONUP:
|
||||
self.clicked()
|
||||
elif msg == WM_MENUCOMMAND and lParam == WM_RBUTTONUP:
|
||||
self._menu()
|
||||
else:
|
||||
return DefWindowProc(hWnd, msg, wParam, lParam)
|
||||
return 1
|
||||
|
||||
|
||||
def die(self):
|
||||
try:
|
||||
Shell_NotifyIcon(NIM_DELETE, ctypes.pointer(self.iconinfo))
|
||||
except:
|
||||
pass
|
||||
ctypes.windll.user32.DestroyWindow(self._hwnd)
|
||||
ctypes.windll.user32.DestroyIcon(self._hicon)
|
||||
self._die = True
|
||||
|
||||
|
||||
def pump(self):
|
||||
try:
|
||||
while not self._pumpqueue.empty():
|
||||
callable = self._pumpqueue.get(False)
|
||||
callable()
|
||||
except Queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
def announce(self, text):
|
||||
self._info_bubble = text
|
||||
|
||||
|
||||
def hideConsole():
|
||||
ctypes.windll.user32.ShowWindow(ctypes.windll.kernel32.GetConsoleWindow(), 0)
|
||||
|
||||
def showConsole():
|
||||
ctypes.windll.user32.ShowWindow(ctypes.windll.kernel32.GetConsoleWindow(), 1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import time
|
||||
def greet():
|
||||
ctypes.windll.user32.ShowWindow(ctypes.windll.kernel32.GetConsoleWindow(), 0)
|
||||
print "Hello"
|
||||
def quit():
|
||||
ni._die = True
|
||||
#sys.exit()
|
||||
def announce():
|
||||
ctypes.windll.user32.ShowWindow(ctypes.windll.kernel32.GetConsoleWindow(), 1)
|
||||
ni.announce("Hello there")
|
||||
|
||||
def clicked():
|
||||
ni.announce("Hello")
|
||||
|
||||
def dynamicTitle():
|
||||
return "!The time is: %s" % time.time()
|
||||
|
||||
ni = NotificationIcon(os.path.join(os.path.dirname(os.path.abspath(__file__)), '../trayicon.ico'), "ZeroNet 0.2.9")
|
||||
ni.items = [
|
||||
(dynamicTitle, False),
|
||||
('Hello', greet),
|
||||
('Title', False),
|
||||
('!Default', greet),
|
||||
('+Popup bubble', announce),
|
||||
'Nothing',
|
||||
'--',
|
||||
('Quit', quit)
|
||||
]
|
||||
ni.clicked = clicked
|
||||
import atexit
|
||||
@atexit.register
|
||||
def goodbye():
|
||||
print "You are now leaving the Python sector."
|
||||
|
||||
ni._run()
|
48
plugins/Trayicon/lib/winfolders.py
Normal file
48
plugins/Trayicon/lib/winfolders.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
''' Get windows special folders without pythonwin
|
||||
Example:
|
||||
import specialfolders
|
||||
start_programs = specialfolders.get(specialfolders.PROGRAMS)
|
||||
|
||||
Code is public domain, do with it what you will.
|
||||
|
||||
Luke Pinner - Environment.gov.au, 2010 February 10
|
||||
'''
|
||||
|
||||
#Imports use _syntax to mask them from autocomplete IDE's
|
||||
import ctypes as _ctypes
|
||||
from ctypes.wintypes import HWND as _HWND, HANDLE as _HANDLE,DWORD as _DWORD,LPCWSTR as _LPCWSTR,MAX_PATH as _MAX_PATH, create_unicode_buffer as _cub
|
||||
_SHGetFolderPath = _ctypes.windll.shell32.SHGetFolderPathW
|
||||
|
||||
#public special folder constants
|
||||
DESKTOP= 0
|
||||
PROGRAMS= 2
|
||||
MYDOCUMENTS= 5
|
||||
FAVORITES= 6
|
||||
STARTUP= 7
|
||||
RECENT= 8
|
||||
SENDTO= 9
|
||||
STARTMENU= 11
|
||||
MYMUSIC= 13
|
||||
MYVIDEOS= 14
|
||||
NETHOOD= 19
|
||||
FONTS= 20
|
||||
TEMPLATES= 21
|
||||
ALLUSERSSTARTMENU= 22
|
||||
ALLUSERSPROGRAMS= 23
|
||||
ALLUSERSSTARTUP= 24
|
||||
ALLUSERSDESKTOP= 25
|
||||
APPLICATIONDATA= 26
|
||||
PRINTHOOD= 27
|
||||
LOCALSETTINGSAPPLICATIONDATA= 28
|
||||
ALLUSERSFAVORITES= 31
|
||||
LOCALSETTINGSTEMPORARYINTERNETFILES=32
|
||||
COOKIES= 33
|
||||
LOCALSETTINGSHISTORY= 34
|
||||
ALLUSERSAPPLICATIONDATA= 35
|
||||
|
||||
def get(intFolder):
|
||||
_SHGetFolderPath.argtypes = [_HWND, _ctypes.c_int, _HANDLE, _DWORD, _LPCWSTR]
|
||||
auPathBuffer = _cub(_MAX_PATH)
|
||||
exit_code=_SHGetFolderPath(0, intFolder, 0, 0, auPathBuffer)
|
||||
return auPathBuffer.value
|
||||
|
BIN
plugins/Trayicon/trayicon.ico
Normal file
BIN
plugins/Trayicon/trayicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
|
@ -162,6 +162,7 @@ class Connection:
|
|||
self.last_recv_time = time.time()
|
||||
self.incomplete_buff_recv += 1
|
||||
self.bytes_recv += len(buff)
|
||||
self.server.bytes_recv += len(buff)
|
||||
if not self.unpacker:
|
||||
self.unpacker = msgpack.Unpacker()
|
||||
self.unpacker.feed(buff)
|
||||
|
@ -198,13 +199,19 @@ class Connection:
|
|||
if config.debug_socket: self.log("Got handshake response: %s, ping: %s" % (message, ping))
|
||||
self.last_ping_delay = ping
|
||||
self.handshake = message
|
||||
self.port = message["fileserver_port"] # Set peer fileserver port
|
||||
if self.handshake.get("port_opened", None) == False: # Not connectable
|
||||
self.port = 0
|
||||
else:
|
||||
self.port = message["fileserver_port"] # Set peer fileserver port
|
||||
else:
|
||||
self.log("Unknown response: %s" % message)
|
||||
elif message.get("cmd"): # Handhsake request
|
||||
if message["cmd"] == "handshake":
|
||||
self.handshake = message["params"]
|
||||
self.port = self.handshake["fileserver_port"] # Set peer fileserver port
|
||||
if self.handshake.get("port_opened", None) == False: # Not connectable
|
||||
self.port = 0
|
||||
else:
|
||||
self.port = self.handshake["fileserver_port"] # Set peer fileserver port
|
||||
if config.debug_socket: self.log("Handshake request: %s" % message)
|
||||
data = self.handshakeInfo()
|
||||
data["cmd"] = "response"
|
||||
|
@ -242,6 +249,7 @@ class Connection:
|
|||
else: # Normal connection
|
||||
data = msgpack.packb(message)
|
||||
self.bytes_sent += len(data)
|
||||
self.server.bytes_sent += len(data)
|
||||
self.sock.sendall(data)
|
||||
self.last_sent_time = time.time()
|
||||
return True
|
||||
|
|
|
@ -22,6 +22,9 @@ class ConnectionServer:
|
|||
self.running = True
|
||||
self.thread_checker = gevent.spawn(self.checkConnections)
|
||||
|
||||
self.bytes_recv = 0
|
||||
self.bytes_sent = 0
|
||||
|
||||
self.zmq_running = False
|
||||
self.zmq_last_connection = None # Last incoming message client
|
||||
|
||||
|
@ -82,6 +85,8 @@ class ConnectionServer:
|
|||
return connection
|
||||
|
||||
# No connection found
|
||||
if port == 0:
|
||||
raise Exception("This peer is not connectable")
|
||||
try:
|
||||
connection = Connection(self, ip, port)
|
||||
self.ips[ip] = connection
|
||||
|
|
|
@ -131,10 +131,10 @@ class FileRequest:
|
|||
address = self.unpackAddress(peer)
|
||||
got_peer_keys.append("%s:%s" % address)
|
||||
if (site.addPeer(*address)): added += 1
|
||||
# Send back peers that is not in the sent list
|
||||
# Send back peers that is not in the sent list and connectable (not port 0)
|
||||
peers = site.peers.values()
|
||||
random.shuffle(peers)
|
||||
packed_peers = [peer.packAddress() for peer in peers if peer.key not in got_peer_keys][0:params["need"]]
|
||||
packed_peers = [peer.packAddress() for peer in peers if not peer.key.endswith(":0") and peer.key not in got_peer_keys][0:params["need"]]
|
||||
if added:
|
||||
self.log.debug("Added %s peers to %s using pex, sending back %s" % (added, site, len(packed_peers)))
|
||||
self.response({"peers": packed_peers})
|
||||
|
|
|
@ -150,7 +150,7 @@ class Peer:
|
|||
if not site: site = self.site # If no site definied request peers for this site
|
||||
peers = self.site.peers.values()
|
||||
random.shuffle(peers)
|
||||
packed_peers = [peer.packAddress() for peer in peers][0:need_num]
|
||||
packed_peers = [peer.packAddress() for peer in peers if not peer.key.endswith(":0")][0:need_num]
|
||||
response = self.request("pex", {"site": site.address, "peers": packed_peers, "need": need_num})
|
||||
if not response or "error" in response:
|
||||
return False
|
||||
|
|
|
@ -319,11 +319,10 @@ class Site:
|
|||
address_hash = hashlib.sha1(self.address).hexdigest()
|
||||
my_peer_id = sys.modules["main"].file_server.peer_id
|
||||
|
||||
# Later, if we have peer exchange
|
||||
"""if sys.modules["main"].file_server.port_opened:
|
||||
if sys.modules["main"].file_server.port_opened:
|
||||
fileserver_port = config.fileserver_port
|
||||
else: # Port not opened, report port 0
|
||||
fileserver_port = 0"""
|
||||
fileserver_port = 0
|
||||
|
||||
fileserver_port = config.fileserver_port
|
||||
s = time.time()
|
||||
|
|
|
@ -112,9 +112,11 @@ class UiServer:
|
|||
|
||||
|
||||
def stop(self):
|
||||
self.log.debug("Stopping...")
|
||||
# Close WS sockets
|
||||
for client in self.server.clients.values():
|
||||
client.ws.close()
|
||||
if "clients" in dir(self.server):
|
||||
for client in self.server.clients.values():
|
||||
client.ws.close()
|
||||
# Close http sockets
|
||||
sock_closed = 0
|
||||
for sock in self.server.sockets.values():
|
||||
|
|
384
src/main.py
384
src/main.py
|
@ -35,11 +35,10 @@ logging.getLogger('').name = "-" # Remove root prefix
|
|||
from Debug import DebugHook
|
||||
if config.debug:
|
||||
console_log.setLevel(logging.DEBUG) # Display everything to console
|
||||
from gevent import monkey; monkey.patch_all(thread=False) # thread=False because of pyfilesystem
|
||||
else:
|
||||
console_log.setLevel(logging.INFO) # Display only important info to console
|
||||
from gevent import monkey; monkey.patch_all() # Make time, thread, socket gevent compatible
|
||||
|
||||
from gevent import monkey; monkey.patch_all(thread=False) # Make time, socket gevent compatible
|
||||
import gevent
|
||||
import time
|
||||
|
||||
|
@ -64,195 +63,196 @@ PluginManager.plugin_manager.loadPlugins()
|
|||
|
||||
# -- Actions --
|
||||
|
||||
@PluginManager.acceptPlugins
|
||||
class Actions:
|
||||
# Default action: Start serving UiServer and FileServer
|
||||
def main(self):
|
||||
logging.info("Version: %s, Python %s, Gevent: %s" % (config.version, sys.version, gevent.__version__))
|
||||
global ui_server, file_server
|
||||
from File import FileServer
|
||||
from Ui import UiServer
|
||||
logging.info("Creating UiServer....")
|
||||
ui_server = UiServer()
|
||||
|
||||
logging.info("Creating FileServer....")
|
||||
file_server = FileServer()
|
||||
|
||||
logging.info("Starting servers....")
|
||||
gevent.joinall([gevent.spawn(ui_server.start), gevent.spawn(file_server.start)])
|
||||
|
||||
|
||||
# Site commands
|
||||
|
||||
def siteCreate(self):
|
||||
logging.info("Generating new privatekey...")
|
||||
from Crypt import CryptBitcoin
|
||||
privatekey = CryptBitcoin.newPrivatekey()
|
||||
logging.info("----------------------------------------------------------------------")
|
||||
logging.info("Site private key: %s" % privatekey)
|
||||
logging.info(" !!! ^ Save it now, required to modify the site ^ !!!")
|
||||
address = CryptBitcoin.privatekeyToAddress(privatekey)
|
||||
logging.info("Site address: %s" % address)
|
||||
logging.info("----------------------------------------------------------------------")
|
||||
|
||||
while True:
|
||||
if raw_input("? Have you secured your private key? (yes, no) > ").lower() == "yes": break
|
||||
else: logging.info("Please, secure it now, you going to need it to modify your site!")
|
||||
|
||||
logging.info("Creating directory structure...")
|
||||
from Site import Site
|
||||
os.mkdir("data/%s" % address)
|
||||
open("data/%s/index.html" % address, "w").write("Hello %s!" % address)
|
||||
|
||||
logging.info("Creating content.json...")
|
||||
site = Site(address)
|
||||
site.content_manager.sign(privatekey=privatekey)
|
||||
site.settings["own"] = True
|
||||
site.saveSettings()
|
||||
|
||||
logging.info("Site created!")
|
||||
|
||||
|
||||
def siteSign(self, address, privatekey=None, inner_path="content.json"):
|
||||
from Site import Site
|
||||
logging.info("Signing site: %s..." % address)
|
||||
site = Site(address, allow_create = False)
|
||||
|
||||
if not privatekey: # If no privatekey in args then ask it now
|
||||
import getpass
|
||||
privatekey = getpass.getpass("Private key (input hidden):")
|
||||
site.content_manager.sign(inner_path=inner_path, privatekey=privatekey, update_changed_files=True)
|
||||
|
||||
|
||||
def siteVerify(self, address):
|
||||
from Site import Site
|
||||
logging.info("Verifing site: %s..." % address)
|
||||
site = Site(address)
|
||||
|
||||
for content_inner_path in site.content_manager.contents:
|
||||
logging.info("Verifing %s signature..." % content_inner_path)
|
||||
if site.content_manager.verifyFile(content_inner_path, site.storage.open(content_inner_path, "rb"), ignore_same=False) == True:
|
||||
logging.info("[OK] %s signed by address %s!" % (content_inner_path, address))
|
||||
else:
|
||||
logging.error("[ERROR] %s not signed by address %s!" % (content_inner_path, address))
|
||||
|
||||
logging.info("Verifying site files...")
|
||||
bad_files = site.storage.verifyFiles()
|
||||
if not bad_files:
|
||||
logging.info("[OK] All file sha512sum matches!")
|
||||
else:
|
||||
logging.error("[ERROR] Error during verifying site files!")
|
||||
|
||||
|
||||
def dbRebuild(self, address):
|
||||
from Site import Site
|
||||
logging.info("Rebuilding site sql cache: %s..." % address)
|
||||
site = Site(address)
|
||||
s = time.time()
|
||||
site.storage.rebuildDb()
|
||||
logging.info("Done in %.3fs" % (time.time()-s))
|
||||
|
||||
|
||||
def dbQuery(self, address, query):
|
||||
from Site import Site
|
||||
import json
|
||||
site = Site(address)
|
||||
result = []
|
||||
for row in site.storage.query(query):
|
||||
result.append(dict(row))
|
||||
print json.dumps(result, indent=4)
|
||||
|
||||
|
||||
def siteAnnounce(self, address):
|
||||
from Site.Site import Site
|
||||
logging.info("Announcing site %s to tracker..." % address)
|
||||
site = Site(address)
|
||||
|
||||
s = time.time()
|
||||
site.announce()
|
||||
print "Response time: %.3fs" % (time.time()-s)
|
||||
print site.peers
|
||||
|
||||
|
||||
def siteNeedFile(self, address, inner_path):
|
||||
from Site import Site
|
||||
site = Site(address)
|
||||
site.announce()
|
||||
print site.needFile(inner_path, update=True)
|
||||
|
||||
|
||||
def sitePublish(self, address, peer_ip=None, peer_port=15441, inner_path="content.json"):
|
||||
global file_server
|
||||
from Site import Site
|
||||
from File import FileServer # We need fileserver to handle incoming file requests
|
||||
|
||||
logging.info("Creating FileServer....")
|
||||
file_server = FileServer()
|
||||
file_server_thread = gevent.spawn(file_server.start, check_sites=False) # Dont check every site integrity
|
||||
file_server.openport()
|
||||
if file_server.port_opened == False:
|
||||
logging.info("Port not opened, passive publishing not supported yet :(")
|
||||
return
|
||||
site = file_server.sites[address]
|
||||
site.settings["serving"] = True # Serving the site even if its disabled
|
||||
if peer_ip: # Announce ip specificed
|
||||
site.addPeer(peer_ip, peer_port)
|
||||
else: # Just ask the tracker
|
||||
logging.info("Gathering peers from tracker")
|
||||
site.announce() # Gather peers
|
||||
site.publish(20, inner_path) # Push to 20 peers
|
||||
time.sleep(3)
|
||||
logging.info("Serving files...")
|
||||
gevent.joinall([file_server_thread])
|
||||
logging.info("Done.")
|
||||
|
||||
|
||||
|
||||
# Crypto commands
|
||||
|
||||
def cryptoPrivatekeyToAddress(self, privatekey=None):
|
||||
from Crypt import CryptBitcoin
|
||||
if not privatekey: # If no privatekey in args then ask it now
|
||||
import getpass
|
||||
privatekey = getpass.getpass("Private key (input hidden):")
|
||||
|
||||
print CryptBitcoin.privatekeyToAddress(privatekey)
|
||||
|
||||
|
||||
# Peer
|
||||
|
||||
def peerPing(self, peer_ip, peer_port):
|
||||
logging.info("Opening a simple connection server")
|
||||
global file_server
|
||||
from Connection import ConnectionServer
|
||||
file_server = ConnectionServer("127.0.0.1", 1234)
|
||||
|
||||
from Peer import Peer
|
||||
logging.info("Pinging 5 times peer: %s:%s..." % (peer_ip, int(peer_port)))
|
||||
peer = Peer(peer_ip, peer_port)
|
||||
for i in range(5):
|
||||
s = time.time()
|
||||
print peer.ping(),
|
||||
print "Response time: %.3fs" % (time.time()-s)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def peerGetFile(self, peer_ip, peer_port, site, filename):
|
||||
logging.info("Opening a simple connection server")
|
||||
global file_server
|
||||
from Connection import ConnectionServer
|
||||
file_server = ConnectionServer()
|
||||
|
||||
from Peer import Peer
|
||||
logging.info("Getting %s/%s from peer: %s:%s..." % (site, filename, peer_ip, peer_port))
|
||||
peer = Peer(peer_ip, peer_port)
|
||||
s = time.time()
|
||||
print peer.getFile(site, filename).read()
|
||||
print "Response time: %.3fs" % (time.time()-s)
|
||||
|
||||
actions = Actions()
|
||||
# Starts here when running zeronet.py
|
||||
def start():
|
||||
action_func = globals()[config.action] # Function reference
|
||||
action_kwargs = config.getActionArguments() # non-config arguments when calling zeronet.py
|
||||
|
||||
action_func(**action_kwargs)
|
||||
|
||||
|
||||
# Start serving UiServer and PeerServer
|
||||
def main():
|
||||
logging.info("Version: %s, Python %s, Gevent: %s" % (config.version, sys.version, gevent.__version__))
|
||||
global ui_server, file_server
|
||||
from File import FileServer
|
||||
from Ui import UiServer
|
||||
logging.info("Creating UiServer....")
|
||||
ui_server = UiServer()
|
||||
|
||||
logging.info("Creating FileServer....")
|
||||
file_server = FileServer()
|
||||
|
||||
logging.info("Starting servers....")
|
||||
gevent.joinall([gevent.spawn(ui_server.start), gevent.spawn(file_server.start)])
|
||||
|
||||
|
||||
# Site commands
|
||||
|
||||
def siteCreate():
|
||||
logging.info("Generating new privatekey...")
|
||||
from Crypt import CryptBitcoin
|
||||
privatekey = CryptBitcoin.newPrivatekey()
|
||||
logging.info("----------------------------------------------------------------------")
|
||||
logging.info("Site private key: %s" % privatekey)
|
||||
logging.info(" !!! ^ Save it now, required to modify the site ^ !!!")
|
||||
address = CryptBitcoin.privatekeyToAddress(privatekey)
|
||||
logging.info("Site address: %s" % address)
|
||||
logging.info("----------------------------------------------------------------------")
|
||||
|
||||
while True:
|
||||
if raw_input("? Have you secured your private key? (yes, no) > ").lower() == "yes": break
|
||||
else: logging.info("Please, secure it now, you going to need it to modify your site!")
|
||||
|
||||
logging.info("Creating directory structure...")
|
||||
from Site import Site
|
||||
os.mkdir("data/%s" % address)
|
||||
open("data/%s/index.html" % address, "w").write("Hello %s!" % address)
|
||||
|
||||
logging.info("Creating content.json...")
|
||||
site = Site(address)
|
||||
site.content_manager.sign(privatekey=privatekey)
|
||||
site.settings["own"] = True
|
||||
site.saveSettings()
|
||||
|
||||
logging.info("Site created!")
|
||||
|
||||
|
||||
def siteSign(address, privatekey=None, inner_path="content.json"):
|
||||
from Site import Site
|
||||
logging.info("Signing site: %s..." % address)
|
||||
site = Site(address, allow_create = False)
|
||||
|
||||
if not privatekey: # If no privatekey in args then ask it now
|
||||
import getpass
|
||||
privatekey = getpass.getpass("Private key (input hidden):")
|
||||
site.content_manager.sign(inner_path=inner_path, privatekey=privatekey, update_changed_files=True)
|
||||
|
||||
|
||||
def siteVerify(address):
|
||||
from Site import Site
|
||||
logging.info("Verifing site: %s..." % address)
|
||||
site = Site(address)
|
||||
|
||||
for content_inner_path in site.content_manager.contents:
|
||||
logging.info("Verifing %s signature..." % content_inner_path)
|
||||
if site.content_manager.verifyFile(content_inner_path, site.storage.open(content_inner_path, "rb"), ignore_same=False) == True:
|
||||
logging.info("[OK] %s signed by address %s!" % (content_inner_path, address))
|
||||
else:
|
||||
logging.error("[ERROR] %s not signed by address %s!" % (content_inner_path, address))
|
||||
|
||||
logging.info("Verifying site files...")
|
||||
bad_files = site.storage.verifyFiles()
|
||||
if not bad_files:
|
||||
logging.info("[OK] All file sha512sum matches!")
|
||||
else:
|
||||
logging.error("[ERROR] Error during verifying site files!")
|
||||
|
||||
|
||||
def dbRebuild(address):
|
||||
from Site import Site
|
||||
logging.info("Rebuilding site sql cache: %s..." % address)
|
||||
site = Site(address)
|
||||
s = time.time()
|
||||
site.storage.rebuildDb()
|
||||
logging.info("Done in %.3fs" % (time.time()-s))
|
||||
|
||||
|
||||
def dbQuery(address, query):
|
||||
from Site import Site
|
||||
import json
|
||||
site = Site(address)
|
||||
result = []
|
||||
for row in site.storage.query(query):
|
||||
result.append(dict(row))
|
||||
print json.dumps(result, indent=4)
|
||||
|
||||
|
||||
def siteAnnounce(address):
|
||||
from Site.Site import Site
|
||||
logging.info("Announcing site %s to tracker..." % address)
|
||||
site = Site(address)
|
||||
|
||||
s = time.time()
|
||||
site.announce()
|
||||
print "Response time: %.3fs" % (time.time()-s)
|
||||
print site.peers
|
||||
|
||||
|
||||
def siteNeedFile(address, inner_path):
|
||||
from Site import Site
|
||||
site = Site(address)
|
||||
site.announce()
|
||||
print site.needFile(inner_path, update=True)
|
||||
|
||||
|
||||
def sitePublish(address, peer_ip=None, peer_port=15441, inner_path="content.json"):
|
||||
global file_server
|
||||
from Site import Site
|
||||
from File import FileServer # We need fileserver to handle incoming file requests
|
||||
|
||||
logging.info("Creating FileServer....")
|
||||
file_server = FileServer()
|
||||
file_server_thread = gevent.spawn(file_server.start, check_sites=False) # Dont check every site integrity
|
||||
file_server.openport()
|
||||
if file_server.port_opened == False:
|
||||
logging.info("Port not opened, passive publishing not supported yet :(")
|
||||
return
|
||||
site = file_server.sites[address]
|
||||
site.settings["serving"] = True # Serving the site even if its disabled
|
||||
if peer_ip: # Announce ip specificed
|
||||
site.addPeer(peer_ip, peer_port)
|
||||
else: # Just ask the tracker
|
||||
logging.info("Gathering peers from tracker")
|
||||
site.announce() # Gather peers
|
||||
site.publish(20, inner_path) # Push to 20 peers
|
||||
time.sleep(3)
|
||||
logging.info("Serving files...")
|
||||
gevent.joinall([file_server_thread])
|
||||
logging.info("Done.")
|
||||
|
||||
|
||||
|
||||
# Crypto commands
|
||||
|
||||
def cryptoPrivatekeyToAddress(privatekey=None):
|
||||
from Crypt import CryptBitcoin
|
||||
if not privatekey: # If no privatekey in args then ask it now
|
||||
import getpass
|
||||
privatekey = getpass.getpass("Private key (input hidden):")
|
||||
|
||||
print CryptBitcoin.privatekeyToAddress(privatekey)
|
||||
|
||||
|
||||
# Peer
|
||||
|
||||
def peerPing(peer_ip, peer_port):
|
||||
logging.info("Opening a simple connection server")
|
||||
global file_server
|
||||
from Connection import ConnectionServer
|
||||
file_server = ConnectionServer("127.0.0.1", 1234)
|
||||
|
||||
from Peer import Peer
|
||||
logging.info("Pinging 5 times peer: %s:%s..." % (peer_ip, int(peer_port)))
|
||||
peer = Peer(peer_ip, peer_port)
|
||||
for i in range(5):
|
||||
s = time.time()
|
||||
print peer.ping(),
|
||||
print "Response time: %.3fs" % (time.time()-s)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def peerGetFile(peer_ip, peer_port, site, filename):
|
||||
logging.info("Opening a simple connection server")
|
||||
global file_server
|
||||
from Connection import ConnectionServer
|
||||
file_server = ConnectionServer()
|
||||
|
||||
from Peer import Peer
|
||||
logging.info("Getting %s/%s from peer: %s:%s..." % (site, filename, peer_ip, peer_port))
|
||||
peer = Peer(peer_ip, peer_port)
|
||||
s = time.time()
|
||||
print peer.getFile(site, filename).read()
|
||||
print "Response time: %.3fs" % (time.time()-s)
|
||||
|
||||
# Call function
|
||||
func = getattr(actions, config.action, None)
|
||||
action_kwargs = config.getActionArguments()
|
||||
func(**action_kwargs)
|
||||
|
|
Loading…
Reference in a new issue