Files
LorenEteval_Furious/Furious/Widget/EditRouting.py
T
2023-08-11 18:33:59 +08:00

1126 lines
36 KiB
Python

from Furious.Action.Routing import (
BUILTIN_ROUTING_TABLE,
BUILTIN_ROUTING,
RoutingChildAction,
routingToIndex,
)
from Furious.Core.Core import XrayCore, Hysteria
from Furious.Gui.Action import Action, Seperator
from Furious.Widget.Widget import (
HeaderView,
Menu,
MessageBox,
PushButton,
StyledItemDelegate,
TableWidget,
TabWidget,
ZoomableTextBrowser,
ZoomablePlainTextEdit,
)
from Furious.Widget.EditConfiguration import (
JSONErrorBox,
SaveConfInfo,
QuestionSaveBox,
QuestionDeleteBox,
)
from Furious.Widget.AssetViewer import AssetViewerWidget
from Furious.Utility.Constants import APP, APPLICATION_NAME, GOLDEN_RATIO, DATA_DIR
from Furious.Utility.Utility import (
RoutesStorage,
StateContext,
SupportConnectedCallback,
bootstrapIcon,
moveToCenter,
)
from Furious.Utility.Translator import Translatable, gettext as _
from Furious.Utility.Theme import DraculaTheme
from PySide6 import QtCore
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (
QAbstractItemView,
QFileDialog,
QGridLayout,
QHBoxLayout,
QHeaderView,
QInputDialog,
QLabel,
QLineEdit,
QMainWindow,
QPlainTextEdit,
QSplitter,
QStyledItemDelegate,
QTableWidget,
QTableWidgetItem,
QTextBrowser,
QVBoxLayout,
QWidget,
)
import ujson
import logging
import functools
logger = logging.getLogger(__name__)
BUILTIN_ROUTING_TEXT = {
'Bypass Mainland China': {
XrayCore.name(): (
f'# Xray-core Routing rules\n'
f'# \n'
f'# This rule is read-only.\n'
f'# \n'
f'# This is a built-in RoutingObject that\n'
f'# implements Bypass Mainland China feature.\n'
f'# \n'
f'# There are four RuleObject(rules) from top\n'
f'# to bottom which implement:\n'
f'# 1. Block ads.(geosite:category-ads-all)\n'
f'# 2. Bypass China site.(geosite:cn)\n'
f'# 3. Bypass private and China IP.(geoip:private, geoip:cn)\n'
f'# 4. Proxy traffic sent from port 0-65535, in this\n'
f'# case meaning any other unmatched traffic.\n'
f'# respectively.\n'
f'# \n'
f'# "geoip.dat" and "geosite.dat" are shipped with\n'
f'# {APPLICATION_NAME} by default. However, you can import\n'
f'# and replace them if you need.\n'
f'# \n'
f'# See more information:\n'
f'# \n'
f'# https://xtls.github.io/config/routing.html\n\n'
)
+ ujson.dumps(
BUILTIN_ROUTING_TABLE['Bypass Mainland China'][XrayCore.name()],
indent=2,
ensure_ascii=False,
escape_forward_slashes=False,
),
Hysteria.name(): (
f'# Hysteria Routing rules\n'
f'# \n'
f'# This rule is read-only.\n'
f'# \n'
f'# Hysteria will use rule file defined in "acl"\n'
f'# key and database file defined in "mmdb" key.\n'
f'# \n'
f'# "bypass-mainland-China.acl" and "country.mmdb"\n'
f'# are shipped with {APPLICATION_NAME} by default.\n'
f'# Their locations are listed below.\n'
f'# \n'
f'# See more information:\n'
f'# \n'
f'# https://hysteria.network/docs/acl/\n\n'
)
+ ujson.dumps(
BUILTIN_ROUTING_TABLE['Bypass Mainland China'][Hysteria.name()],
indent=2,
ensure_ascii=False,
escape_forward_slashes=False,
),
},
'Bypass Iran': {
XrayCore.name(): (
f'# Xray-core Routing rules\n'
f'# \n'
f'# This rule is read-only.\n'
f'# \n'
f'# This is a built-in RoutingObject that\n'
f'# implements Bypass Iran feature.\n'
f'# \n'
f'# There are four RuleObject(rules) from top\n'
f'# to bottom which implement:\n'
f'# 1. Block ads.(geosite:category-ads-all, iran:ads)\n'
f'# 2. Bypass Iran site.(iran:ir, iran:other)\n'
f'# 3. Bypass private and Iran IP.(geoip:private, geoip:ir)\n'
f'# 4. Proxy traffic sent from port 0-65535, in this\n'
f'# case meaning any other unmatched traffic.\n'
f'# respectively.\n'
f'# \n'
f'# "geoip.dat", "geosite.dat" and "iran.dat" are shipped\n'
f'# with {APPLICATION_NAME} by default. However, you can import\n'
f'# and replace them if you need.\n'
f'# \n'
f'# See more information:\n'
f'# \n'
f'# https://xtls.github.io/config/routing.html\n\n'
)
+ ujson.dumps(
BUILTIN_ROUTING_TABLE['Bypass Iran'][XrayCore.name()],
indent=2,
ensure_ascii=False,
escape_forward_slashes=False,
),
Hysteria.name(): (
f'# Hysteria Routing rules\n'
f'# \n'
f'# This rule is read-only.\n'
f'# \n'
f'# Hysteria will use rule file defined in "acl"\n'
f'# key and database file defined in "mmdb" key.\n'
f'# \n'
f'# "bypass-Iran.acl" and "country.mmdb" are\n'
f'# shipped with {APPLICATION_NAME} by default.\n'
f'# Their locations are listed below.\n'
f'# \n'
f'# See more information:\n'
f'# \n'
f'# https://hysteria.network/docs/acl/\n\n'
)
+ ujson.dumps(
BUILTIN_ROUTING_TABLE['Bypass Iran'][Hysteria.name()],
indent=2,
ensure_ascii=False,
escape_forward_slashes=False,
),
},
'Global': {
XrayCore.name(): (
f'# Xray-core Routing rules\n'
f'# \n'
f'# This rule is read-only.\n'
f'# \n'
f'# This is a built-in RoutingObject that\n'
f'# implements Global feature.\n'
f'# \n'
f'# There is only one RuleObject(rules)\n'
f'# which implements:\n'
f'# 1. Proxy traffic sent from port 0-65535, in this\n'
f'# case meaning all traffic.\n'
f'# \n'
f'# See more information:\n'
f'# \n'
f'# https://xtls.github.io/config/routing.html\n\n'
)
+ ujson.dumps(
BUILTIN_ROUTING_TABLE['Global'][XrayCore.name()],
indent=2,
ensure_ascii=False,
escape_forward_slashes=False,
),
Hysteria.name(): (
f'# Hysteria Routing rules\n'
f'# \n'
f'# This rule is read-only.\n'
f'# \n'
f'# If no rule file or mmdb file is provided, hysteria\n'
f'# will fall back to proxy all traffic.\n\n'
)
+ ujson.dumps(
BUILTIN_ROUTING_TABLE['Global'][Hysteria.name()],
indent=2,
ensure_ascii=False,
escape_forward_slashes=False,
),
},
'Custom': {
XrayCore.name(): (
f'# Xray-core Routing rules\n'
f'# \n'
f'# This rule is read-only.\n'
f'# \n'
f'# "Custom" will use RoutingObject defined\n'
f'# in current user configuration.\n\n'
),
Hysteria.name(): (
f'# Hysteria Routing rules\n'
f'# \n'
f'# This rule is read-only.\n'
f'# \n'
f'# "Custom" will use rule file and mmdb file\n'
f'# defined in current user configuration.\n\n'
),
},
}
DEFAULT_USER_ROUTING = {
XrayCore.name(): {
'domainStrategy': 'IPIfNonMatch',
'domainMatcher': 'hybrid',
'rules': [
# Proxy everything
{
'type': 'field',
'port': '0-65535',
'outboundTag': 'proxy',
},
],
},
Hysteria.name(): {
'acl': '',
'mmdb': str(DATA_DIR / 'hysteria' / 'country.mmdb'),
},
}
USER_ROUTING_TEXT = {
XrayCore.name(): (
f'# Xray-core Routing rules\n'
f'# \n'
f'# Define your own routing rules here.\n'
f'# \n'
f'# Need any help? You can check those built-in\n'
f'# routing rules shipped with {APPLICATION_NAME} first.\n'
f'# \n'
f'# Note: JSON does not support comments. Any\n'
f'# lines starting with "#" will be discarded\n'
f'# by {APPLICATION_NAME} forever.\n\n'
)
+ ujson.dumps(
DEFAULT_USER_ROUTING[XrayCore.name()],
indent=2,
ensure_ascii=False,
escape_forward_slashes=False,
),
Hysteria.name(): (
f'# Hysteria Routing rules\n'
f'# \n'
f'# Define your own routing rules here.\n'
f'# A built-in mmdb filepath is defined\n'
f'# for convenience.\n'
f'# \n'
f'# Need any help? You can check those built-in\n'
f'# routing rules shipped with {APPLICATION_NAME} first.\n'
f'# \n'
f'# Note: JSON does not support comments. Any\n'
f'# lines starting with "#" will be discarded\n'
f'# by {APPLICATION_NAME} forever.\n'
f'# \n'
f'# Note: If any one of filepath is given but\n'
f'# does not exist, hysteria will fall back to\n'
f'# proxy all traffic.\n\n'
)
+ ujson.dumps(
DEFAULT_USER_ROUTING[Hysteria.name()],
indent=2,
ensure_ascii=False,
escape_forward_slashes=False,
),
}
class ImportAssetFileAction(Action):
def __init__(self, **kwargs):
super().__init__(_('Import Asset File...'), **kwargs)
def triggeredCallback(self, checked):
APP().editRoutingWidget.assetViewer.show()
def questionFastReconnect(saveConfInfo):
saveConfInfo.setWindowTitle(_('Hint'))
saveConfInfo.setText(
_(
f'{APPLICATION_NAME} is currently connected.\n\n'
f'The new configuration will take effect the next time you connect.'
)
)
# Show the MessageBox and wait for user to close it
choice = saveConfInfo.exec()
if choice == MessageBox.ButtonRole.AcceptRole.value:
# Reconnect
APP().tray.ConnectAction.connectingAction(
showProgressBar=True,
showRoutingChangedMessage=False,
)
else:
# OK. Do nothing
pass
class SaveAction(Action):
def __init__(self, **kwargs):
super().__init__(_('Save'), icon=bootstrapIcon('save.svg'), **kwargs)
self.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_S
)
)
self.saveErrorBox = JSONErrorBox(icon=MessageBox.Icon.Critical)
self.saveConfInfo = SaveConfInfo(icon=MessageBox.Icon.Information)
def getParentIndex(self):
routingEditor = self.parent()
routingEditorRef = (
APP().editRoutingWidget.editRoutingTableWidget.routingEditorRef
)
for index, editor in enumerate(routingEditorRef[len(BUILTIN_ROUTING) :]):
if id(routingEditor) == id(editor):
# Found reference
return index
return -1
def save(self, successCallback=None):
if not self.parent().modified:
return True
coreList = []
textList = []
jsonList = []
editorTab = self.parent().editorTab
for index in range(editorTab.count()):
core = editorTab.tabText(index)
plainText = editorTab.widget(index).toPlainText()
text = functools.reduce(
lambda x, y: x + y,
list(
filter(
# Discard any comments
lambda x: not x.strip().startswith('#'),
plainText.split('\n'),
)
),
)
coreList.append(core)
textList.append(plainText)
try:
jsonList.append(ujson.loads(text))
except ujson.JSONDecodeError as decodeError:
self.saveErrorBox.decodeError = f'{core}: {decodeError}'
self.saveErrorBox.setWindowTitle(
_('Error saving routing configuration')
)
self.saveErrorBox.setText(self.saveErrorBox.getText())
# Show the MessageBox and wait for user to close it
self.saveErrorBox.exec()
return False
# Fast save
# Check parent(i.e. routing option) is not deleted by user.
parentIndex = self.getParentIndex()
if parentIndex >= 0:
for core, json in zip(coreList, jsonList):
APP().editRoutingWidget.RoutesList[parentIndex][core] = json
# Sync it
RoutesStorage.sync()
# Refresh last saved text
self.parent().editorLastSavedText = textList
self.parent().markAsSaved()
if callable(successCallback):
successCallback()
if parentIndex == routingToIndex() - len(BUILTIN_ROUTING):
# Activated routing modified
if APP().tray.ConnectAction.isConnected():
questionFastReconnect(self.saveConfInfo)
else:
# Corresponding routing option has been deleted. Fake saved
logger.info('trying to save a deleted routing. Ignore')
self.parent().markAsSaved()
if callable(successCallback):
successCallback()
return True
def triggeredCallback(self, checked):
self.save()
class ExitAction(Action):
def __init__(self, **kwargs):
super().__init__(_('Exit'), **kwargs)
def triggeredCallback(self, checked):
self.parent().hide()
class ZoomInAction(Action):
def __init__(self, **kwargs):
super().__init__(_('Zoom In'), **kwargs)
self.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Plus
)
)
def triggeredCallback(self, checked):
for editor in self.parent().editorRef:
editor.zoomIn()
class ZoomOutAction(Action):
def __init__(self, **kwargs):
super().__init__(_('Zoom Out'), **kwargs)
self.setShortcut(
QtCore.QKeyCombination(
QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Minus
)
)
def triggeredCallback(self, checked):
for editor in self.parent().editorRef:
editor.zoomOut()
class RoutingTextBrowser(ZoomableTextBrowser):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.routingEditorWidget = kwargs.get('parent')
def wheelEvent(self, event):
if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier:
delta = event.angleDelta().y()
if delta > 0:
for editor in self.routingEditorWidget.editorRef:
editor.zoomIn()
if delta < 0:
for editor in self.routingEditorWidget.editorRef:
editor.zoomOut()
else:
super().wheelEvent(event)
class RoutingPlainTextEdit(ZoomablePlainTextEdit):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.routingEditorWidget = kwargs.get('parent')
def wheelEvent(self, event):
if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier:
delta = event.angleDelta().y()
if delta > 0:
for editor in self.routingEditorWidget.editorRef:
editor.zoomIn()
if delta < 0:
for editor in self.routingEditorWidget.editorRef:
editor.zoomOut()
else:
super().wheelEvent(event)
class RoutingEditor(Translatable, SupportConnectedCallback, QMainWindow):
def __init__(self, isBuiltin, *args, **kwargs):
super().__init__(*args, **kwargs)
self.isBuiltin = isBuiltin
self.setWindowTitle(_('Edit Routing'))
if APP().tray is not None and APP().tray.ConnectAction.isConnected():
self.setWindowIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg'))
else:
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
# MessageBox
self.questionSaveBox = QuestionSaveBox(
icon=MessageBox.Icon.Question, parent=self
)
self.title = ''
# Modified flag
self.modified = False
self.modifiedMark = ' *'
self.editorRef = []
self.editorLastSavedText = []
self.themeReference = []
self.editorTab = TabWidget(parent=self)
self.setCentralWidget(self.editorTab)
if self.isBuiltin:
fileMenuActions = [
ImportAssetFileAction(parent=self),
Seperator(),
ExitAction(parent=self),
]
else:
fileMenuActions = [
ImportAssetFileAction(parent=self),
Seperator(),
SaveAction(parent=self),
Seperator(),
ExitAction(parent=self),
]
viewMenuActions = [
ZoomInAction(parent=self),
ZoomOutAction(parent=self),
]
for menu in (fileMenuActions, viewMenuActions):
for action in menu:
if isinstance(action, Action):
if hasattr(self, f'{action}'):
logger.warning(f'{self} already has action {action}')
setattr(self, f'{action}', action)
self._fileMenu = Menu(*fileMenuActions, title=_('File'), parent=self)
self._viewMenu = Menu(*viewMenuActions, title=_('View'), parent=self)
self.menuBar().addMenu(self._fileMenu)
self.menuBar().addMenu(self._viewMenu)
def addTabWithData(self, core, text):
if self.isBuiltin:
editor = RoutingTextBrowser(parent=self)
editor.setLineWrapMode(QTextBrowser.LineWrapMode.NoWrap)
editor.setStyleSheet(
DraculaTheme.getStyleSheet(
widgetName='QTextBrowser',
fontFamily=APP().customFontName,
)
)
else:
editor = RoutingPlainTextEdit(parent=self)
editor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
editor.setStyleSheet(
DraculaTheme.getStyleSheet(
widgetName='QPlainTextEdit',
fontFamily=APP().customFontName,
)
)
@QtCore.Slot(bool)
def handleModification(changed):
if changed:
editor.document().setModified(False)
self.markAsModified()
editor.modificationChanged.connect(handleModification)
# Theme
self.themeReference.append(DraculaTheme(editor.document()))
editor.blockSignals(True)
editor.setPlainText(text)
editor.blockSignals(False)
self.editorRef.append(editor)
self.editorLastSavedText.append(text)
self.editorTab.addTab(editor, core)
def markAsModified(self):
# Built-in is read-only. Should not be modified
assert not self.isBuiltin
self.modified = True
self.setWindowTitle(self.title + self.modifiedMark)
def markAsSaved(self):
# Built-in is read-only. Should not be modified
assert not self.isBuiltin
self.modified = False
self.setWindowTitle(self.title)
def questionSave(self):
if self.modified:
choice = self.questionSaveBox.exec()
if choice == MessageBox.ButtonRole.AcceptRole.value:
# Save
# If save success, close the window.
return self.SaveAction.save(successCallback=lambda: self.hide())
if choice == MessageBox.ButtonRole.DestructiveRole.value:
# Discard
self.hide()
# Restore last saved text
for editor, text in zip(self.editorRef, self.editorLastSavedText):
editor.blockSignals(True)
editor.setPlainText(text)
editor.blockSignals(False)
self.markAsSaved() # Fake saved
return True
if choice == MessageBox.ButtonRole.RejectRole.value:
# Cancel. Do nothing
return False
else:
self.hide()
return True
def closeEvent(self, event):
event.ignore()
self.questionSave()
def connectedCallback(self):
self.setWindowIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg'))
def disconnectedCallback(self):
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
def retranslate(self):
if self.isBuiltin:
# Built-in routing is translatable
with StateContext(self):
self.setWindowTitle(_(self.windowTitle()))
class EditRoutingTableHorizontalHeader(HeaderView):
def __init__(self, *args, **kwargs):
super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs)
self.sectionResized.connect(self.handleSectionResized)
@QtCore.Slot(int, int, int)
def handleSectionResized(self, index, oldSize, newSize):
# Keys are string when loaded from json
self.parent().sectionSizeTable[str(index)] = newSize
class EditRoutingTableVerticalHeader(HeaderView):
def __init__(self, *args, **kwargs):
super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs)
class EditRoutingTableWidget(Translatable, SupportConnectedCallback, TableWidget):
# Might be extended in the future
HEADER_LABEL = [
'Remark',
'Type',
]
# Corresponds to header label
BUILTIN_ITEM_GET_FUNC = [
lambda row: _(BUILTIN_ROUTING[row]),
lambda row: _('Built-in'),
]
USER_ITEM_GET_FUNC = [
lambda text: text,
lambda text: _('User Defined'),
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.editRoutingWidget = kwargs.get('parent')
self.questionDeleteBox = QuestionDeleteBox(
icon=MessageBox.Icon.Question, parent=self.parent()
)
# Handy reference
self.RoutesList = self.editRoutingWidget.RoutesList
self.routingEditorRef = []
# Column count
self.setColumnCount(len(EditRoutingTableWidget.HEADER_LABEL))
# Delegate
self.delegate = StyledItemDelegate(parent=self)
self.setItemDelegate(self.delegate)
# Install custom header
self.setHorizontalHeader(EditRoutingTableHorizontalHeader(self))
self.initTableFromData()
self.activateItemByIndex(routingToIndex(), activate=True)
# Horizontal header resize mode
for index in range(self.columnCount()):
if index < self.columnCount() - 1:
self.horizontalHeader().setSectionResizeMode(
index, QHeaderView.ResizeMode.Interactive
)
else:
self.horizontalHeader().setSectionResizeMode(
index, QHeaderView.ResizeMode.Stretch
)
try:
# Restore horizontal section size
self.sectionSizeTable = ujson.loads(APP().RoutesWidgetSectionSizeTable)
# Block resize callback
self.horizontalHeader().blockSignals(True)
for key, value in self.sectionSizeTable.items():
self.horizontalHeader().resizeSection(int(key), value)
# Unblock resize callback
self.horizontalHeader().blockSignals(False)
except Exception:
# Any non-exit exceptions
# Leave keys as strings since they will be
# loaded as string from json
self.sectionSizeTable = {
str(row): self.horizontalHeader().defaultSectionSize()
for row in range(self.columnCount())
}
self.setHorizontalHeaderLabels(
list(_(label) for label in EditRoutingTableWidget.HEADER_LABEL)
)
for column in range(self.horizontalHeader().count()):
self.horizontalHeaderItem(column).setFont(QFont(APP().customFontName))
# Install custom header
self.setVerticalHeader(EditRoutingTableVerticalHeader(self))
# Selection
self.setSelectionColor('#008AE1')
self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
# No drag and drop
self.setDragEnabled(False)
self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
self.setDropIndicatorShown(False)
self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction)
# Signals
self.itemActivated.connect(self.handleItemActivated)
def initTableFromData(self):
for row in range(len(BUILTIN_ROUTING)):
self.appendDataByColumn(
lambda column: EditRoutingTableWidget.BUILTIN_ITEM_GET_FUNC[column](row)
)
routingEditor = self.appendRoutingEditor(
self.item(row, 0).text(), isBuiltin=True
)
for core in [XrayCore.name(), Hysteria.name()]:
routingEditor.addTabWithData(
core,
BUILTIN_ROUTING_TEXT[BUILTIN_ROUTING[row]][core],
)
for row in range(
len(BUILTIN_ROUTING),
len(BUILTIN_ROUTING) + len(self.RoutesList),
):
routesIndex = row - len(BUILTIN_ROUTING)
remark = self.RoutesList[routesIndex]['remark']
self.appendDataByColumn(
lambda column: EditRoutingTableWidget.USER_ITEM_GET_FUNC[column](remark)
)
routingEditor = self.appendRoutingEditor(
self.item(row, 0).text(), isBuiltin=False
)
for core in [XrayCore.name(), Hysteria.name()]:
routingEditor.addTabWithData(
core,
ujson.dumps(
self.RoutesList[routesIndex][core],
indent=2,
ensure_ascii=False,
escape_forward_slashes=False,
),
)
@QtCore.Slot(QTableWidgetItem)
def handleItemActivated(self, item):
self.routingEditorRef[item.row()].show()
def appendRoutingEditor(self, title, isBuiltin=False):
routingEditor = RoutingEditor(isBuiltin)
routingEditor.title = title
routingEditor.setWindowTitle(title)
try:
routingEditor.setGeometry(
100,
100,
*list(int(size) for size in APP().RoutesEditorWindowSize.split(',')),
)
except Exception:
# Any non-exit exceptions
routingEditor.setGeometry(100, 100, 728 * GOLDEN_RATIO, 728)
moveToCenter(routingEditor)
self.routingEditorRef.append(routingEditor)
return routingEditor
def deleteSelectedItem(self):
# Builtin routing cannot be deleted
indexes = list(filter(lambda x: x >= len(BUILTIN_ROUTING), self.selectedIndex))
if len(indexes) == 0:
# Nothing to do
return
self.questionDeleteBox.isMulti = bool(len(indexes) > 1)
self.questionDeleteBox.possibleRemark = f'{self.item(indexes[0], 0).text()}'
self.questionDeleteBox.setText(self.questionDeleteBox.getText())
if self.questionDeleteBox.exec() == MessageBox.StandardButton.No.value:
# Do not delete
return
try:
index = int(APP().Routing)
except ValueError:
takedCurrentRouting = False
else:
if index + len(BUILTIN_ROUTING) in indexes:
# Current routing will be deleted
takedCurrentRouting = True
else:
takedCurrentRouting = False
routingAction = APP().tray.RoutingAction
for i in range(len(indexes)):
takedRow = indexes[i] - i
self.removeRow(takedRow)
self.routingEditorRef.pop(takedRow)
self.RoutesList.pop(takedRow - len(BUILTIN_ROUTING))
# Remove entry from routing menu
routingAction.removeAction(routingAction.menu().actions()[takedRow])
routingIndex = routingToIndex()
if not takedCurrentRouting and takedRow < routingIndex:
APP().Routing = str(routingIndex - len(BUILTIN_ROUTING) - 1)
# Sync it
RoutesStorage.sync()
if takedCurrentRouting:
currentRow = self.currentIndex().row()
self.setCurrentItem(self.item(currentRow, 0))
# Trigger current
routingAction.menu().actions()[currentRow].trigger()
def appendDataByColumn(self, func):
row = self.rowCount()
self.insertRow(row)
for column in range(self.columnCount()):
item = QTableWidgetItem(func(column))
item.setFont(QFont(APP().customFontName))
item.setFlags(
QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable
)
self.setItem(row, column, item)
def connectedCallback(self):
self.setSelectionColor('#EB212E')
# Reactivate with possible color
self.activateItemByIndex(routingToIndex(), activate=True)
def disconnectedCallback(self):
self.setSelectionColor('#008AE1')
# Reactivate with possible color
self.activateItemByIndex(routingToIndex(), activate=True)
def retranslate(self):
self.setHorizontalHeaderLabels(
list(_(label) for label in EditRoutingTableWidget.HEADER_LABEL)
)
for row in range(self.rowCount()):
if row < len(BUILTIN_ROUTING):
for column in range(self.columnCount()):
item = self.item(row, column)
item.setText(_(item.text()))
else:
for column in range(self.columnCount()):
# Do not translate user remark
if column != 0:
item = self.item(row, column)
item.setText(_(item.text()))
class EditRoutingWidget(Translatable, SupportConnectedCallback, QMainWindow):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setWindowTitle(_('Edit Routing'))
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
self.assetViewer = AssetViewerWidget()
try:
self.StorageObj = RoutesStorage.toObject(APP().CustomRouting)
# Check model. Shallow copy
self.RoutesList = self.StorageObj['model']
except Exception:
# Any non-exit exceptions
self.StorageObj = RoutesStorage.init()
# Shallow copy
self.RoutesList = self.StorageObj['model']
# Clear it
RoutesStorage.clear()
self.editRoutingTableWidget = EditRoutingTableWidget(parent=self)
self.editorTab = TabWidget(self)
self.editorTab.addTab(self.editRoutingTableWidget, _('Routing Rules'))
# Buttons
self.addButton = PushButton(_('Add'))
self.addButton.clicked.connect(lambda: self.addRoute())
self.deleteButton = PushButton(_('Delete'))
self.deleteButton.clicked.connect(lambda: self.deleteSelectedItem())
# Button Layout
self.buttonWidget = QWidget()
self.buttonWidgetLayout = QGridLayout(parent=self.buttonWidget)
self.buttonWidgetLayout.addWidget(self.addButton, 0, 0)
self.buttonWidgetLayout.addWidget(self.deleteButton, 0, 1)
self.fakeCentralWidget = QWidget()
self.fakeCentralWidgetLayout = QVBoxLayout(self.fakeCentralWidget)
self.fakeCentralWidgetLayout.addWidget(self.editorTab)
self.fakeCentralWidgetLayout.addWidget(self.buttonWidget)
self.setCentralWidget(self.fakeCentralWidget)
fileMenuActions = [
ImportAssetFileAction(parent=self),
Seperator(),
ExitAction(parent=self),
]
for menu in (fileMenuActions,):
for action in menu:
if isinstance(action, Action):
if hasattr(self, f'{action}'):
logger.warning(f'{self} already has action {action}')
setattr(self, f'{action}', action)
self._fileMenu = Menu(*fileMenuActions, title=_('File'), parent=self)
self.menuBar().addMenu(self._fileMenu)
try:
self.setGeometry(
100,
100,
*list(int(size) for size in APP().RoutesWidgetWindowSize.split(',')),
)
except Exception:
# Any non-exit exceptions
self.setGeometry(100, 100, 640, 640 * GOLDEN_RATIO)
moveToCenter(self)
def addRoute(self):
# Get text for a new item from the user
text, ok = QInputDialog.getText(
self, _('Add routing'), _('Enter routing remark:')
)
if text and ok:
self.editRoutingTableWidget.appendDataByColumn(
lambda column: EditRoutingTableWidget.USER_ITEM_GET_FUNC[column](text)
)
routingEditor = self.editRoutingTableWidget.appendRoutingEditor(
text, isBuiltin=False
)
for core in [XrayCore.name(), Hysteria.name()]:
routingEditor.addTabWithData(
core,
USER_ROUTING_TEXT[core],
)
self.RoutesList.append({'remark': text, **DEFAULT_USER_ROUTING})
# Sync it
RoutesStorage.sync()
routingAction = APP().tray.RoutingAction
# Add entry in routing menu
routingAction.addAction(
RoutingChildAction(text, checkable=True, checked=False)
)
def deleteSelectedItem(self):
self.editRoutingTableWidget.deleteSelectedItem()
def activateItemByIndex(self, index, activate=True):
self.editRoutingTableWidget.activateItemByIndex(index, activate)
def syncSettings(self):
APP().RoutesWidgetWindowSize = (
f'{self.geometry().width()},{self.geometry().height()}'
)
if len(self.editRoutingTableWidget.routingEditorRef):
APP().RoutesEditorWindowSize = (
f'{self.editRoutingTableWidget.routingEditorRef[0].geometry().width()},'
f'{self.editRoutingTableWidget.routingEditorRef[0].geometry().height()}'
)
APP().RoutesWidgetSectionSizeTable = ujson.dumps(
self.editRoutingTableWidget.sectionSizeTable,
ensure_ascii=False,
escape_forward_slashes=False,
)
def keyPressEvent(self, event):
if event.key() == QtCore.Qt.Key.Key_Delete:
self.deleteSelectedItem()
else:
super().keyPressEvent(event)
def closeEvent(self, event):
event.ignore()
self.syncSettings()
self.hide()
def connectedCallback(self):
self.setWindowIcon(bootstrapIcon('rocket-takeoff-connected-dark.svg'))
def disconnectedCallback(self):
self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg'))
def retranslate(self):
with StateContext(self):
self.setWindowTitle(_(self.windowTitle()))