Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# This file is part of Xpra. 

2# Copyright (C) 2018-2020 Antoine Martin <antoine@xpra.org> 

3# Xpra is released under the terms of the GNU GPL v2, or, at your option, any 

4# later version. See the file COPYING for details. 

5 

6""" 

7Utility functions for loading xdg menus 

8using python-xdg 

9""" 

10 

11import os 

12import re 

13import sys 

14import glob 

15from io import BytesIO 

16from typing import Generator as generator #@UnresolvedImport, @UnusedImport 

17from threading import Lock 

18 

19from xpra.util import envbool, envint, print_nested_dict, first_time, engs, ellipsizer 

20from xpra.os_util import load_binary_file, monotonic_time, OSEnvContext 

21from xpra.log import Logger, add_debug_category 

22 

23log = Logger("exec", "menu") 

24 

25LOAD_GLOB = envbool("XPRA_XDG_LOAD_GLOB", True) 

26EXPORT_ICONS = envbool("XPRA_XDG_EXPORT_ICONS", True) 

27MAX_ICON_SIZE = envint("XPRA_XDG_MAX_ICON_SIZE", 65536) 

28DEBUG_COMMANDS = os.environ.get("XPRA_XDG_DEBUG_COMMANDS", "").split(",") 

29 

30large_icons = [] 

31 

32INKSCAPE_RE = b'\sinkscape:[a-zA-Z]*=["a-zA-Z0-9]*' 

33 

34def isvalidtype(v): 

35 if isinstance(v, (list, tuple, generator)): 

36 if not v: 

37 return True 

38 return all(isvalidtype(x) for x in v) 

39 return isinstance(v, (bytes, str, bool, int)) 

40 

41def export(entry, properties): 

42 name = entry.getName() 

43 props = {} 

44 if any(x and name.lower().find(x.lower())>=0 for x in DEBUG_COMMANDS): 

45 l = log.info 

46 else: 

47 l = log 

48 for prop in properties: 

49 fn_name = "get%s" % prop 

50 try: 

51 fn = getattr(entry, fn_name, None) 

52 if fn: 

53 v = fn() 

54 if isinstance(v, (list, tuple, generator)): 

55 l("%s=%s (%s)", prop, v, type(x for x in v)) 

56 else: 

57 l("%s=%s (%s)", prop, v, type(v)) 

58 if not isvalidtype(v): 

59 log.warn("Warning: found invalid type for '%s': %s", v, type(v)) 

60 else: 

61 props[prop] = v 

62 except Exception as e: 

63 l("error on %s", entry, exc_info=True) 

64 log.error("Error parsing '%s': %s", prop, e) 

65 l("properties(%s)=%s", name, props) 

66 #load icon binary data: 

67 icon = props.get("Icon") 

68 icondata = load_icon_from_theme(icon) 

69 if icondata: 

70 bdata, ext = icondata 

71 props["IconData"] = bdata 

72 props["IconType"] = ext 

73 return props 

74 

75_Rsvg = None 

76def load_Rsvg(): 

77 global _Rsvg 

78 if _Rsvg is None: 

79 import gi 

80 try: 

81 gi.require_version('Rsvg', '2.0') 

82 from gi.repository import Rsvg 

83 log("load_Rsvg() Rsvg=%s", Rsvg) 

84 _Rsvg = Rsvg 

85 except (ValueError, ImportError) as e: 

86 if first_time("no-rsvg"): 

87 log.warn("Warning: cannot resize svg icons,") 

88 log.warn(" the Rsvg bindings were not found:") 

89 log.warn(" %s", e) 

90 _Rsvg = False 

91 return _Rsvg 

92 

93 

94def load_icon_from_file(filename): 

95 log("load_icon_from_file(%s)", filename) 

96 if filename.endswith("xpm"): 

97 from PIL import Image 

98 try: 

99 img = Image.open(filename) 

100 buf = BytesIO() 

101 img.save(buf, "PNG") 

102 pngicondata = buf.getvalue() 

103 buf.close() 

104 return pngicondata, "png" 

105 except ValueError as e: 

106 log("Image.open(%s)", filename, exc_info=True) 

107 except Exception as e: 

108 log("Image.open(%s)", filename, exc_info=True) 

109 log.error("Error loading '%s':", filename) 

110 log.error(" %s", e) 

111 #fallback to PixbufLoader: 

112 try: 

113 from xpra.gtk_common.gtk_util import pixbuf_save_to_memory 

114 data = load_binary_file(filename) 

115 from gi.repository import GdkPixbuf 

116 loader = GdkPixbuf.PixbufLoader() 

117 loader.write(data) 

118 loader.close() 

119 pixbuf = loader.get_pixbuf() 

120 pngicondata = pixbuf_save_to_memory(pixbuf, "png") 

121 return pngicondata, "png" 

122 except Exception as e: 

123 log("pixbuf error loading %s", filename, exc_info=True) 

124 log.error("Error loading '%s':", filename) 

125 log.error(" %s", e) 

126 icondata = load_binary_file(filename) 

127 if not icondata: 

128 return None 

129 if filename.endswith("svg") and len(icondata)>MAX_ICON_SIZE//2: 

130 #try to resize it 

131 size = len(icondata) 

132 pngdata = svg_to_png(filename, icondata) 

133 if pngdata: 

134 log("reduced size of SVG icon %s, from %i bytes to %i bytes as PNG", 

135 filename, size, len(pngdata)) 

136 icondata = pngdata 

137 filename = filename[:-3]+"png" 

138 log("got icon data from '%s': %i bytes", filename, len(icondata)) 

139 if len(icondata)>MAX_ICON_SIZE and first_time("icon-size-warning-%s" % filename): 

140 global large_icons 

141 large_icons.append((filename, len(icondata))) 

142 return icondata, os.path.splitext(filename)[1].lstrip(".") 

143 

144def svg_to_png(filename, icondata, w=128, h=128): 

145 Rsvg = load_Rsvg() 

146 if not Rsvg: 

147 return None 

148 try: 

149 import cairo 

150 #'\sinkscape:[a-zA-Z]*=["a-zA-Z0-9]*' 

151 img = cairo.ImageSurface(cairo.FORMAT_ARGB32, 128, 128) 

152 ctx = cairo.Context(img) 

153 handle = Rsvg.Handle.new_from_data(icondata) 

154 handle.render_cairo(ctx) 

155 buf = BytesIO() 

156 img.write_to_png(buf) 

157 icondata = buf.getvalue() 

158 buf.close() 

159 return icondata 

160 except Exception: 

161 log("svg_to_png%s", (icondata, w, h), exc_info=True) 

162 if re.findall(INKSCAPE_RE, icondata): 

163 #try again after stripping the bogus inkscape attributes 

164 #as some rsvg versions can't handle that (ie: Debian Bullseye) 

165 icondata = re.sub(INKSCAPE_RE, b"", icondata) 

166 return svg_to_png(filename, icondata, w, h) 

167 log.error("Error: failed to convert svg icon") 

168 log.error(" '%s':", filename) 

169 log.error(" %i bytes, %s", len(icondata), ellipsizer(icondata)) 

170 

171 

172def load_icon_from_theme(icon_name, theme=None): 

173 if not EXPORT_ICONS or not icon_name: 

174 return None 

175 from xdg import IconTheme 

176 filename = IconTheme.getIconPath(icon_name, theme=theme) 

177 if not filename: 

178 return None 

179 return load_icon_from_file(filename) 

180 

181def load_glob_icon(submenu_data, main_dirname="categories"): 

182 if not LOAD_GLOB or not EXPORT_ICONS: 

183 return None 

184 #doesn't work with IconTheme.getIconPath, 

185 #so do it the hard way: 

186 from xdg import IconTheme 

187 icondirs = getattr(IconTheme, "icondirs", []) 

188 if not icondirs: 

189 return None 

190 for x in ("Icon", "Name", "GenericName"): 

191 name = submenu_data.get(x) 

192 if name: 

193 icondata = find_icon(main_dirname, icondirs, name) 

194 if icondata: 

195 return icondata 

196 return None 

197 

198def find_icon(main_dirname, icondirs, name): 

199 extensions = ("png", "svg", "xpm") 

200 pathnames = [] 

201 for dn in (main_dirname, "*"): 

202 for d in icondirs: 

203 for ext in extensions: 

204 pathnames += [ 

205 os.path.join(d, "*", "*", dn, "%s.%s" % (name, ext)), 

206 os.path.join(d, "*", dn, "*", "%s.%s" % (name, ext)), 

207 ] 

208 for pathname in pathnames: 

209 filenames = glob.glob(pathname) 

210 log("glob(%s) matches %i filenames", pathname, len(filenames)) 

211 if filenames: 

212 for f in filenames: 

213 icondata = load_icon_from_file(f) 

214 if icondata: 

215 log("found icon for '%s' with glob '%s': %s", name, pathname, f) 

216 return icondata 

217 return None 

218 

219 

220def load_xdg_entry(de): 

221 #not exposed: 

222 #"MimeType" is an re 

223 #"Version" is a float 

224 props = export(de, ( 

225 "Type", "VersionString", "Name", "GenericName", "NoDisplay", 

226 "Comment", "Icon", "Hidden", "OnlyShowIn", "NotShowIn", 

227 "Exec", "TryExec", "Path", "Terminal", "MimeTypes", 

228 "Categories", "StartupNotify", "StartupWMClass", "URL", 

229 )) 

230 if de.getTryExec(): 

231 try: 

232 command = de.findTryExec() 

233 except Exception: 

234 command = de.getTryExec() 

235 else: 

236 command = de.getExec() 

237 props["command"] = command 

238 icondata = props.get("IconData") 

239 if not icondata: 

240 #try harder: 

241 icondata = load_glob_icon(de, "apps") 

242 if icondata: 

243 bdata, ext = icondata 

244 props["IconData"] = bdata 

245 props["IconType"] = ext 

246 return props 

247 

248def load_xdg_menu(submenu): 

249 #log.info("submenu %s: %s, %s", name, submenu, dir(submenu)) 

250 submenu_data = export(submenu, [ 

251 "Name", "GenericName", "Comment", 

252 "Path", "Icon", 

253 ]) 

254 icondata = submenu_data.get("IconData") 

255 if not icondata: 

256 #try harder: 

257 icondata = load_glob_icon(submenu_data, "categories") 

258 if icondata: 

259 bdata, ext = icondata 

260 submenu_data["IconData"] = bdata 

261 submenu_data["IconType"] = ext 

262 entries_data = submenu_data.setdefault("Entries", {}) 

263 for entry in submenu.getEntries(): 

264 #can we have more than 2 levels of submenus? 

265 from xdg.Menu import MenuEntry 

266 if isinstance(entry, MenuEntry): 

267 de = entry.DesktopEntry 

268 name = de.getName() 

269 try: 

270 entries_data[name] = load_xdg_entry(de) 

271 except Exception as e: 

272 log("load_xdg_menu(%s)", submenu, exc_info=True) 

273 log.error("Error loading desktop entry '%s':", name) 

274 log.error(" %s", e) 

275 return submenu_data 

276 

277def remove_icons(menu_data): 

278 def noicondata(d): 

279 return dict((k,v) for k,v in d.items() if k!="IconData") 

280 filt = {} 

281 for category, cdef in menu_data.items(): 

282 fcdef = dict(cdef) 

283 entries = dict(fcdef.get("Entries", {})) 

284 for entry, edef in tuple(entries.items()): 

285 entries[entry] = noicondata(edef) 

286 fcdef["Entries"] = entries 

287 filt[category] = fcdef 

288 return filt 

289 

290 

291load_lock = Lock() 

292xdg_menu_data = None 

293def load_xdg_menu_data(force_reload=False): 

294 global xdg_menu_data, large_icons 

295 with load_lock: 

296 if not xdg_menu_data or force_reload: 

297 large_icons = [] 

298 start = monotonic_time() 

299 xdg_menu_data = do_load_xdg_menu_data() 

300 end = monotonic_time() 

301 if xdg_menu_data: 

302 l = sum(len(x) for x in xdg_menu_data.values()) 

303 log.info("%s %i start menu entries from %i sub-menus in %.1f seconds", 

304 "reloaded" if force_reload else "loaded", l, len(xdg_menu_data), end-start) 

305 if large_icons: 

306 log.warn("Warning: found %i large icon%s:", len(large_icons), engs(large_icons)) 

307 for filename, size in large_icons: 

308 log.warn(" '%s' (%i KB)", filename, size//1024) 

309 log.warn(" more bandwidth will be used by the start menu data") 

310 return xdg_menu_data 

311 

312def do_load_xdg_menu_data(): 

313 try: 

314 from xdg.Menu import parse, Menu 

315 except ImportError: 

316 log("do_load_xdg_menu_data()", exc_info=True) 

317 if first_time("no-python-xdg"): 

318 log.warn("Warning: cannot use application menu data:") 

319 log.warn(" no python-xdg module") 

320 return None 

321 menu = None 

322 error = None 

323 with OSEnvContext(): 

324 #see ticket #2340, 

325 #invalid values for XDG_CONFIG_DIRS can cause problems, 

326 #so try unsetting it if we can't load the menus with it: 

327 for cd in (False, True): 

328 if cd: 

329 os.environ.pop("XDG_CONFIG_DIRS", None) 

330 #see ticket #2174, 

331 #things may break if the prefix is not set, 

332 #and it isn't set when logging in via ssh 

333 for prefix in (None, "", "gnome-", "kde-"): 

334 if prefix is not None: 

335 os.environ["XDG_MENU_PREFIX"] = prefix 

336 try: 

337 menu = parse() 

338 break 

339 except Exception as e: 

340 log("do_load_xdg_menu_data()", exc_info=True) 

341 error = e 

342 menu = None 

343 if menu is None: 

344 if error: 

345 log.error("Error parsing xdg menu data:") 

346 log.error(" %s", error) 

347 log.error(" this is either a bug in python-xdg,") 

348 log.error(" or an invalid system menu configuration") 

349 return None 

350 menu_data = {} 

351 for submenu in menu.getEntries(): 

352 if isinstance(submenu, Menu) and submenu.Visible: 

353 name = submenu.getName() 

354 try: 

355 menu_data[name] = load_xdg_menu(submenu) 

356 except Exception as e: 

357 log("load_xdg_menu_data()", exc_info=True) 

358 log.error("Error loading submenu '%s':", name) 

359 log.error(" %s", e) 

360 return menu_data 

361 

362 

363def main(): 

364 from xpra.platform import program_context 

365 with program_context("XDG-Menu-Helper", "XDG Menu Helper"): 

366 for x in list(sys.argv): 

367 if x in ("-v", "--verbose"): 

368 sys.argv.remove(x) 

369 add_debug_category("menu") 

370 log.enable_debug() 

371 def icon_fmt(icondata): 

372 return "%i bytes" % len(icondata) 

373 if len(sys.argv)>1: 

374 for x in sys.argv[1:]: 

375 if os.path.isabs(x): 

376 v = load_icon_from_file(x) 

377 print("load_icon_from_file(%s)=%s" % (x, v)) 

378 else: 

379 menu = load_xdg_menu_data() 

380 if menu: 

381 print_nested_dict(menu, vformat={"IconData" : icon_fmt}) 

382 else: 

383 print("no menu data found") 

384 return 0 

385 

386if __name__ == "__main__": 

387 r = main() 

388 sys.exit(r)