1# serve.py - TortoiseHg dialog to start web server 2# 3# Copyright 2010 Yuya Nishihara <yuya@tcha.org> 4# 5# This software may be used and distributed according to the terms of the 6# GNU General Public License version 2, incorporated herein by reference. 7 8from __future__ import absolute_import 9 10import os 11import tempfile 12 13from .qtcore import ( 14 Qt, 15 pyqtSlot, 16) 17from .qtgui import ( 18 QDialog, 19 QSystemTrayIcon, 20) 21 22from mercurial import ( 23 error, 24 pycompat, 25 util, 26) 27 28from ..util import ( 29 hglib, 30 paths, 31 wconfig, 32) 33from ..util.i18n import _ 34from . import ( 35 cmdcore, 36 cmdui, 37 qtlib, 38) 39from .serve_ui import Ui_ServeDialog 40from .webconf import WebconfForm 41 42if hglib.TYPE_CHECKING: 43 from typing import ( 44 Any, 45 List, 46 Text, 47 Tuple, 48 Optional, 49 ) 50 from .qtgui import ( 51 QWidget, 52 ) 53 from mercurial import ( 54 ui as uimod, 55 ) 56 from ..util.typelib import ( 57 IniConfig, 58 ) 59 60 61class ServeDialog(QDialog): 62 """Dialog for serving repositories via web""" 63 def __init__(self, ui, webconf, parent=None): 64 # type: (uimod.ui, Optional[IniConfig], Optional[QWidget]) -> None 65 super(ServeDialog, self).__init__(parent) 66 self.setWindowFlags((self.windowFlags() | Qt.WindowMinimizeButtonHint) 67 & ~Qt.WindowContextHelpButtonHint) 68 self.setWindowIcon(qtlib.geticon('hg-serve')) 69 70 self._qui = Ui_ServeDialog() 71 self._qui.setupUi(self) 72 73 self._initwebconf(webconf) 74 self._initcmd(ui) 75 self._initactions() 76 self._updateform() 77 78 def _initcmd(self, ui): 79 # type: (uimod.ui) -> None 80 # TODO: forget old logs? 81 self._log_edit = cmdui.LogWidget(self) 82 self._qui.details_tabs.addTab(self._log_edit, _('Log')) 83 # as of hg 3.0, hgweb does not cooperate with command-server channel 84 self._agent = cmdcore.CmdAgent(ui, self, worker='proc') 85 self._agent.outputReceived.connect(self._log_edit.appendLog) 86 self._agent.busyChanged.connect(self._updateform) 87 88 def _initwebconf(self, webconf): 89 # type: (Optional[IniConfig]) -> None 90 self._webconf_form = WebconfForm(webconf=webconf, parent=self) 91 self._qui.details_tabs.addTab(self._webconf_form, _('Repositories')) 92 93 def _initactions(self): 94 # type: () -> None 95 self._qui.start_button.clicked.connect(self.start) 96 self._qui.stop_button.clicked.connect(self.stop) 97 98 @pyqtSlot() 99 def _updateform(self): 100 # type: () -> None 101 """update form availability and status text""" 102 self._updatestatus() 103 self._qui.start_button.setEnabled(not self.isstarted()) 104 self._qui.stop_button.setEnabled(self.isstarted()) 105 self._qui.settings_button.setEnabled(not self.isstarted()) 106 self._qui.port_edit.setEnabled(not self.isstarted()) 107 self._webconf_form.setEnabled(not self.isstarted()) 108 109 def _updatestatus(self): 110 # type: () -> None 111 if self.isstarted(): 112 # TODO: escape special chars 113 link = '<a href="%s">%s</a>' % (self.rooturl, self.rooturl) 114 msg = _('Running at %s') % link 115 else: 116 msg = _('Stopped') 117 118 self._qui.status_edit.setText(msg) 119 120 @pyqtSlot() 121 def start(self): 122 # type: () -> None 123 """Start web server""" 124 if self.isstarted(): 125 return 126 127 self._agent.runCommand(self._cmdargs()) 128 129 def _cmdargs(self): 130 # type: () -> List[Text] 131 """Build command args to run server""" 132 a = ['serve', '--port', str(self.port), '-v'] 133 if self._singlerepo: 134 a += ['-R', self._singlerepo] 135 else: 136 a += ['--web-conf', self._tempwebconf()] 137 return a 138 139 def _tempwebconf(self): 140 # type: () -> Text 141 """Save current webconf to temporary file; return its path""" 142 if not hasattr(self._webconf, 'write'): 143 return hglib.tounicode(self._webconf.path) # pytype: disable=attribute-error 144 145 assert isinstance(self._webconf, wconfig._wconfig) # help pytype 146 147 fd, fname = tempfile.mkstemp(prefix=b'webconf_', 148 dir=qtlib.gettempdir()) 149 f = os.fdopen(fd, 'w') 150 try: 151 self._webconf.write(f) 152 return hglib.tounicode(fname) 153 finally: 154 f.close() 155 156 @property 157 def _webconf(self): 158 # type: () -> IniConfig 159 """Selected webconf object""" 160 return self._webconf_form.webconf 161 162 @property 163 def _singlerepo(self): 164 # type: () -> Optional[Text] 165 """Return repository path if serving single repository""" 166 # TODO: The caller crashes if this returns None with: 167 # `'ServeDialog' object has no attribute '_singlerepo'` 168 # NOTE: we cannot use web-conf to serve single repository at '/' path 169 if len(self._webconf[b'paths']) != 1: 170 return 171 path = self._webconf.get(b'paths', b'/') 172 if path and b'*' not in path: # exactly a single repo (no wildcard) 173 return hglib.tounicode(path) 174 175 @pyqtSlot() 176 def stop(self): 177 # type: () -> None 178 """Stop web server""" 179 self._agent.abortCommands() 180 181 def reject(self): 182 # type: () -> None 183 self.stop() 184 super(ServeDialog, self).reject() 185 186 def isstarted(self): 187 # type: () -> bool 188 """Is the web server running?""" 189 return self._agent.isBusy() 190 191 @property 192 def rooturl(self): 193 # type: () -> Text 194 """Returns the root URL of the web server""" 195 # TODO: scheme, hostname ? 196 return 'http://localhost:%d' % self.port 197 198 @property 199 def port(self): 200 # type: () -> int 201 """Port number of the web server""" 202 return int(self._qui.port_edit.value()) 203 204 def setport(self, port): 205 # type: (int) -> None 206 self._qui.port_edit.setValue(port) 207 208 def keyPressEvent(self, event): 209 if self.isstarted() and event.key() == Qt.Key_Escape: 210 self.stop() 211 return 212 213 return super(ServeDialog, self).keyPressEvent(event) 214 215 def closeEvent(self, event): 216 if self.isstarted(): 217 self._minimizetotray() 218 event.ignore() 219 return 220 221 return super(ServeDialog, self).closeEvent(event) 222 223 @util.propertycache 224 def _trayicon(self): 225 icon = QSystemTrayIcon(self.windowIcon(), parent=self) 226 icon.activated.connect(self._restorefromtray) 227 icon.setToolTip(self.windowTitle()) 228 # TODO: context menu 229 return icon 230 231 # TODO: minimize to tray by minimize button 232 233 @pyqtSlot() 234 def _minimizetotray(self): 235 self._trayicon.show() 236 self._trayicon.showMessage(_('TortoiseHg Web Server'), 237 _('Running at %s') % self.rooturl) 238 self.hide() 239 240 @pyqtSlot() 241 def _restorefromtray(self): 242 self._trayicon.hide() 243 self.show() 244 245 @pyqtSlot() 246 def on_settings_button_clicked(self): 247 from tortoisehg.hgqt import settings 248 settings.SettingsDialog(parent=self, focus='web.name').exec_() 249 250 251def _asconfigliststr(value): 252 # type: (bytes) -> bytes 253 r""" 254 >>> _asconfigliststr(b'foo') 255 b'foo' 256 >>> _asconfigliststr(b'foo bar') 257 b'"foo bar"' 258 >>> _asconfigliststr(b'foo,bar') 259 b'"foo,bar"' 260 >>> _asconfigliststr(b'foo "bar"') 261 b'"foo \\"bar\\""' 262 """ 263 # ui.configlist() uses isspace(), which is locale-dependent 264 if any(c.isspace() or c == b',' for c in pycompat.iterbytestr(value)): 265 return b'"' + value.replace(b'"', b'\\"') + b'"' 266 else: 267 return value 268 269def _readconfig(ui, repopath, webconfpath): 270 # type: (uimod.ui, Optional[bytes], Optional[bytes]) -> Tuple[uimod.ui, Optional[IniConfig]] 271 """Create new ui and webconf object and read appropriate files""" 272 lui = ui.copy() 273 if webconfpath: 274 lui.readconfig(webconfpath) 275 # TODO: handle file not found 276 c = wconfig.readfile(webconfpath) 277 c.path = os.path.abspath(webconfpath) 278 return lui, c 279 elif repopath: # imitate webconf for single repo 280 lui.readconfig(os.path.join(repopath, b'.hg', b'hgrc'), repopath) 281 c = wconfig.config() 282 try: 283 if not os.path.exists(os.path.join(repopath, b'.hgsub')): 284 # no _asconfigliststr(repopath) for now, because ServeDialog 285 # cannot parse it as a list in single-repo mode. 286 c.set(b'paths', b'/', repopath) 287 else: 288 # since hg 8cbb59124e67, path entry is parsed as a list 289 base = hglib.shortreponame(lui) or os.path.basename(repopath) 290 c.set(b'paths', base, 291 _asconfigliststr(os.path.join(repopath, b'**'))) 292 except (EnvironmentError, error.Abort, error.RepoError): 293 c.set(b'paths', b'/', repopath) 294 return lui, c 295 else: 296 return lui, None 297 298def run(ui, *pats, **opts): 299 # type: (uimod.ui, Any, Any) -> ServeDialog 300 # TODO: No known caller provides **opts so bytes vs str is unknown 301 repopath = opts.get('root') or paths.find_root_bytes() 302 webconfpath = opts.get('web_conf') or opts.get('webdir_conf') 303 304 lui, webconf = _readconfig(ui, repopath, webconfpath) 305 dlg = ServeDialog(lui, webconf=webconf) 306 try: 307 dlg.setport(int(lui.config(b'web', b'port'))) 308 except ValueError: 309 pass 310 311 if repopath or webconfpath: 312 dlg.start() 313 return dlg 314