可用性改动
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# 概述
|
# 概述
|
||||||
"潜进"是为高中语文古诗词设计的科学辅助记忆/抽背/翻译/理解工具
|
"潜进"是为高中语文古诗词设计的科学辅助记忆/抽背/翻译/理解工具
|
||||||
"潜进"和 Anki 一样, 基于经实践检验的 SM-2 算法来近似并迭代记忆最佳间隔时间表, 以最大化学习效率并提高记忆稳定性和完整度
|
"潜进"和 Anki 一样, 基于经实践检验的 SM-2 算法来近似并迭代记忆最佳间隔时间表, 以最大化学习效率并提高记忆稳定性和完整度
|
||||||
此外, "潜进" 对古诗词做了优化, 支持逐字翻译预览与基于生成式人工智能的语法结构解析, 另支持微软神经网络语音朗读, 以强化文言文阅读整体能力
|
此外, "潜进" 对古诗词做了优化, 支持逐字翻译预览与基于生成式人工智能接入的语法结构解析, 另支持微软神经网络语音朗读, 以强化文言文阅读整体能力
|
||||||
"潜进"通过 Textual 实现美观, 轻量, 跨平台的用户界面, 无需繁复的键盘输入, 只需一个浏览器/终端, 即可通过鼠标/触摸屏实现全部输入操作
|
"潜进"通过 Textual 实现美观, 轻量, 跨平台的用户界面, 无需繁复的键盘输入, 只需一个浏览器/终端模拟器, 即可通过鼠标/触摸屏实现全部输入操作
|
Binary file not shown.
BIN
__pycache__/reactor.cpython-313.pyc
Normal file
BIN
__pycache__/reactor.cpython-313.pyc
Normal file
Binary file not shown.
0
generate_audio_cache.py
Normal file
0
generate_audio_cache.py
Normal file
86
main.py
86
main.py
@@ -1,12 +1,19 @@
|
|||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.widgets import Header, Footer, ListView, ListItem, Label, Static, Button
|
from textual.widgets import Header, Footer, ListView, ListItem, Label, Static, Button
|
||||||
from textual.containers import Container
|
from textual.containers import Container, Horizontal
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
import particles as parti
|
import particles as pt
|
||||||
|
from reactor import Reactor
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import threading
|
||||||
|
import edge_tts as tts
|
||||||
|
from playsound import playsound
|
||||||
|
from textual.logging import TextualHandler
|
||||||
|
|
||||||
ver = '0.2.1'
|
ver = '0.2.1'
|
||||||
|
|
||||||
|
debug = TextualHandler(stderr=True)
|
||||||
|
|
||||||
class MemScreen(Screen):
|
class MemScreen(Screen):
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
("d", "toggle_dark", "改变色调"),
|
("d", "toggle_dark", "改变色调"),
|
||||||
@@ -28,19 +35,21 @@ class MemScreen(Screen):
|
|||||||
btn = dict()
|
btn = dict()
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
atoms_file: str = 'test_atoms.json',
|
nucleon_file: pt.AtomicFile,
|
||||||
tasked_num: int = 8, # 记忆最小单元数目
|
electron_file: pt.AtomicFile,
|
||||||
|
tasked_num: int
|
||||||
):
|
):
|
||||||
super().__init__(name=None, id=None, classes=None)
|
super().__init__(name=None, id=None, classes=None)
|
||||||
self.memobj = MemObject(atoms_file=atoms_file, tasked_num=tasked_num)
|
self.reactor = Reactor(nucleon_file, electron_file, tasked_num)
|
||||||
self.memobj.next_round()
|
self.stage = 1
|
||||||
self.memobj.update_runtime(1)
|
self.stage += self.reactor.set_round_templated(self.stage)
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header(show_clock=True)
|
yield Header(show_clock=True)
|
||||||
with Container(id="main_container"):
|
with Container(id="main_container"):
|
||||||
yield Label("", id="round_label")
|
yield Label(self.reactor.round_title, id="round_title")
|
||||||
yield Label("记住了吗?", id="question")
|
yield Label("记住了吗?", id="question")
|
||||||
yield Static(self.memobj.runtime['current_atom'].content, id="sentence")
|
yield Static(self.reactor.current_atom[1].content, id="sentence")
|
||||||
yield Static("", id="feedback") # 用于显示反馈
|
yield Static("", id="feedback") # 用于显示反馈
|
||||||
yield Label(self._get_progress_text(), id="progress")
|
yield Label(self._get_progress_text(), id="progress")
|
||||||
with Container(id="button_container"):
|
with Container(id="button_container"):
|
||||||
@@ -57,30 +66,22 @@ class MemScreen(Screen):
|
|||||||
|
|
||||||
|
|
||||||
def _get_progress_text(self):
|
def _get_progress_text(self):
|
||||||
return f"{self.memobj.runtime['left'] + 1}/{self.memobj.runtime['total']}"
|
return f"{len(self.reactor.procession) - self.reactor.index}/{len(self.reactor.procession)}"
|
||||||
|
|
||||||
def _get_round_text(self):
|
|
||||||
t = {
|
|
||||||
'A': "复习模式",
|
|
||||||
'B': "建立新记忆",
|
|
||||||
'C': "总复习"
|
|
||||||
}
|
|
||||||
return "当前模式: " + t[self.memobj.runtime['stage']]
|
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
# 首次挂载时调用
|
# 首次挂载时调用
|
||||||
self._update_ui()
|
self._update_ui()
|
||||||
|
|
||||||
def _update_ui(self):
|
def _update_ui(self):
|
||||||
self.query_one("#round_label", Label).update(self._get_round_text())
|
self.query_one("#round_title", Label).update(self.reactor.round_title)
|
||||||
self.query_one("#sentence", Static).update(self.memobj.runtime['current_atom'].content)
|
self.query_one("#sentence", Static).update(self.reactor.current_atom[1].content)
|
||||||
self.query_one("#progress", Label).update(self._get_progress_text())
|
self.query_one("#progress", Label).update(self._get_progress_text())
|
||||||
self.query_one("#feedback", Static).update("") # 清除任何之前的反馈消息
|
self.query_one("#feedback", Static).update("") # 清除任何之前的反馈消息
|
||||||
|
|
||||||
def _show_finished_screen(self, message):
|
def _show_finished_screen(self, message):
|
||||||
self.query_one("#question", Label).update(message)
|
self.query_one("#question", Label).update(message)
|
||||||
self.query_one("#sentence", Static).update("已经完成记忆任务")
|
self.query_one("#sentence", Static).update("已经完成记忆任务")
|
||||||
self.query_one("#round_label").display = False
|
self.query_one("#round_title").display = False
|
||||||
self.query_one("#progress").display = False
|
self.query_one("#progress").display = False
|
||||||
for i in range(6):
|
for i in range(6):
|
||||||
self.query_one(f"#q{i}", Button).display = False
|
self.query_one(f"#q{i}", Button).display = False
|
||||||
@@ -93,21 +94,20 @@ class MemScreen(Screen):
|
|||||||
btnid = event.button.id
|
btnid = event.button.id
|
||||||
btnid = str(btnid)
|
btnid = str(btnid)
|
||||||
quality = int(btnid.replace('q', ''))
|
quality = int(btnid.replace('q', ''))
|
||||||
assessment = self.memobj.report(self.memobj.runtime['current_atom'], quality)
|
assessment = self.reactor.report(self.reactor.current_atom, quality)
|
||||||
if assessment == 1:
|
if assessment == 1:
|
||||||
# 需要复习
|
# 需要复习
|
||||||
feedback_label.update(f"评分为 {quality}, 已经加入至复习, 请重复记忆")
|
feedback_label.update(f"评分为 {quality}, 已经加入至复习, 请重复记忆")
|
||||||
else:
|
else:
|
||||||
ret = self.memobj.update_runtime(1)
|
ret = self.reactor.forward(1)
|
||||||
if ret == 1:
|
if ret == -1:
|
||||||
self.memobj.switch_to_extra_review()
|
if self.reactor.round_set == 0:
|
||||||
self.memobj.update_runtime(1)
|
if self.stage == 3:
|
||||||
elif ret == 2:
|
# NOTE # self.reactor.save()
|
||||||
self.memobj.next_round()
|
|
||||||
self.memobj.update_runtime(1)
|
|
||||||
elif ret == 3:
|
|
||||||
self.memobj.save()
|
|
||||||
self._show_finished_screen("今日目标已完成")
|
self._show_finished_screen("今日目标已完成")
|
||||||
|
else:
|
||||||
|
self.stage += 1
|
||||||
|
self.reactor.set_round_templated(self.stage)
|
||||||
return
|
return
|
||||||
#feedback_label.update("") # 清除反馈消息
|
#feedback_label.update("") # 清除反馈消息
|
||||||
self._update_ui()
|
self._update_ui()
|
||||||
@@ -116,10 +116,10 @@ class MemScreen(Screen):
|
|||||||
|
|
||||||
def action_play_voice(self):
|
def action_play_voice(self):
|
||||||
def play():
|
def play():
|
||||||
cache = Path(f"./cache/voice/{self.memobj.runtime['current_atom'].content}.wav")
|
cache = pathlib.Path(f"./cache/voice/{self.reactor.current_atom[1].content}.wav")
|
||||||
if not cache.exists():
|
if not cache.exists():
|
||||||
communicate = tts.Communicate(self.memobj.runtime['current_atom'].content, "zh-CN-YunjianNeural")
|
communicate = tts.Communicate(self.reactor.current_atom[1].content, "zh-CN-YunjianNeural")
|
||||||
communicate.save_sync(f"./cache/voice/{self.memobj.runtime['current_atom'].content}.wav")
|
communicate.save_sync(f"./cache/voice/{self.reactor.current_atom[1].content}.wav")
|
||||||
playsound(str(cache))
|
playsound(str(cache))
|
||||||
threading.Thread(target=play).start()
|
threading.Thread(target=play).start()
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ class PreparationScreen(Screen):
|
|||||||
("escape", "quit_app", "退出")
|
("escape", "quit_app", "退出")
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, nucleon_file: parti.AtomicFile, electron_file: parti.AtomicFile) -> None:
|
def __init__(self, nucleon_file: pt.AtomicFile, electron_file: pt.AtomicFile) -> None:
|
||||||
super().__init__(name=None, id=None, classes=None)
|
super().__init__(name=None, id=None, classes=None)
|
||||||
self.nucleon_file = nucleon_file
|
self.nucleon_file = nucleon_file
|
||||||
self.electron_file = electron_file
|
self.electron_file = electron_file
|
||||||
@@ -161,12 +161,12 @@ class PreparationScreen(Screen):
|
|||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
pass
|
pass
|
||||||
#if event.button.id == "start_memorizing_button":
|
if event.button.id == "start_memorizing_button":
|
||||||
# #init_file(Path(self.atom_file).name)
|
#init_file(Path(self.atom_file).name)
|
||||||
# newscr = proc.MemScreen(Path(self.atom_file).name)
|
newscr = MemScreen(self.nucleon_file, self.electron_file, 8)
|
||||||
# self.app.push_screen(
|
self.app.push_screen(
|
||||||
# newscr
|
newscr
|
||||||
# )
|
)
|
||||||
#if event.button.id == "edit_metadata_button":
|
#if event.button.id == "edit_metadata_button":
|
||||||
# init_file(Path(self.atom_file).name)
|
# init_file(Path(self.atom_file).name)
|
||||||
# os.system("reset;nano ./data/" + str(Path(self.atom_file).name.replace(".txt", "_atoms.json")))
|
# os.system("reset;nano ./data/" + str(Path(self.atom_file).name.replace(".txt", "_atoms.json")))
|
||||||
@@ -205,13 +205,13 @@ class FileSelectorScreen(Screen):
|
|||||||
return
|
return
|
||||||
|
|
||||||
selected_filename = str(selected_label.renderable)
|
selected_filename = str(selected_label.renderable)
|
||||||
nucleon_file = parti.AtomicFile(pathlib.Path("./nucleon") / selected_filename, "nucleon")
|
nucleon_file = pt.AtomicFile(pathlib.Path("./nucleon") / selected_filename, "nucleon")
|
||||||
electron_file_path = pathlib.Path("./electron") / selected_filename
|
electron_file_path = pathlib.Path("./electron") / selected_filename
|
||||||
if electron_file_path.exists():
|
if electron_file_path.exists():
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
electron_file_path.touch()
|
electron_file_path.touch()
|
||||||
electron_file = parti.AtomicFile(pathlib.Path("./electron") / selected_filename, "electron")
|
electron_file = pt.AtomicFile(pathlib.Path("./electron") / selected_filename, "electron")
|
||||||
# self.notify(f"已选择: {selected_filename}", timeout=2)
|
# self.notify(f"已选择: {selected_filename}", timeout=2)
|
||||||
self.app.push_screen(PreparationScreen(nucleon_file, electron_file))
|
self.app.push_screen(PreparationScreen(nucleon_file, electron_file))
|
||||||
|
|
||||||
|
42
particles.py
42
particles.py
@@ -1,23 +1,16 @@
|
|||||||
import pathlib
|
import pathlib
|
||||||
import toml
|
import toml
|
||||||
import time
|
import time
|
||||||
import copy
|
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
|
|
||||||
class Aux():
|
class Aux():
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_daystamp():
|
def get_daystamp():
|
||||||
return (time.time() // (24*3600))
|
return (time.time() // (24*3600))
|
||||||
|
|
||||||
class Atom():
|
|
||||||
"""原子: 由电子(分析数据)和核子(材料元数据)组成的反应(运行时)中间对象"""
|
|
||||||
|
|
||||||
class Electron():
|
class Electron():
|
||||||
"""电子: 记忆分析数据及算法"""
|
"""电子: 记忆分析元数据及算法"""
|
||||||
algorithm = "SM-2"
|
algorithm = "SM-2" # 暂时使用 SM-2 算法进行记忆拟合, 考虑 SM-15 替代
|
||||||
"""
|
"""
|
||||||
origin = "陈情表" # 来源
|
|
||||||
content = "" # 内容
|
content = "" # 内容
|
||||||
efactor = 2.5 # 易度系数, 越大越简单, 最大为5
|
efactor = 2.5 # 易度系数, 越大越简单, 最大为5
|
||||||
real_rept = 0 # (实际)重复次数
|
real_rept = 0 # (实际)重复次数
|
||||||
@@ -26,7 +19,7 @@ class Electron():
|
|||||||
last_date = 0 # 上一次复习的时间戳
|
last_date = 0 # 上一次复习的时间戳
|
||||||
next_date = 0 # 将要复习的时间戳
|
next_date = 0 # 将要复习的时间戳
|
||||||
is_activated = 0 # 激活状态
|
is_activated = 0 # 激活状态
|
||||||
# *NOTE: 这里的"时间戳" 是以天为单位的整数, 即 UNIX 时间戳除以一天的秒数取整
|
# *NOTE: 此处"时间戳"是以天为单位的整数, 即 UNIX 时间戳除以一天的秒数取整
|
||||||
last_modify = 0 # 最后修改时间戳(此处是UNIX时间戳)
|
last_modify = 0 # 最后修改时间戳(此处是UNIX时间戳)
|
||||||
"""
|
"""
|
||||||
def __init__(self, content: str, data: dict):
|
def __init__(self, content: str, data: dict):
|
||||||
@@ -47,25 +40,29 @@ class Electron():
|
|||||||
setattr(self, var, value)
|
setattr(self, var, value)
|
||||||
self.last_modify = time.time()
|
self.last_modify = time.time()
|
||||||
|
|
||||||
def update(self, quality):
|
def revisor(self, quality):
|
||||||
"""
|
"""SM-2 算法迭代决策机制实现
|
||||||
根据 quality(0 ~ 5) 进行参数迭代
|
根据 quality(0 ~ 5) 进行参数迭代最佳间隔
|
||||||
quality 由主程序评估
|
quality 由主程序评估
|
||||||
|
|
||||||
|
Args:
|
||||||
|
quality (int): 记忆保留率量化参数
|
||||||
"""
|
"""
|
||||||
if quality == -1:
|
if quality == -1:
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
self.efactor = self.efactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))
|
self.efactor = self.efactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))
|
||||||
self.efactor = max(1.3, self.efactor)
|
self.efactor = max(1.3, self.efactor)
|
||||||
|
|
||||||
if quality < 3:
|
if quality < 3:
|
||||||
# 如果回忆质量低于 3,重置重复次数
|
# 若保留率低于 3,重置重复次数
|
||||||
self.rept = 0
|
self.rept = 0
|
||||||
self.interval = 0 # 设为0,以便下面重新计算I(1)
|
self.interval = 0 # 设为0,以便下面重新计算 I(1)
|
||||||
else:
|
else:
|
||||||
self.rept += 1
|
self.rept += 1
|
||||||
self.real_rept += 1
|
self.real_rept += 1
|
||||||
|
|
||||||
if self.rept == 0: # 刚被重置或首次遇到
|
if self.rept == 0: # 刚被重置或初次激活后复习
|
||||||
self.interval = 1 # I(1)
|
self.interval = 1 # I(1)
|
||||||
elif self.rept == 1:
|
elif self.rept == 1:
|
||||||
self.interval = 6 # I(2) 经验公式
|
self.interval = 6 # I(2) 经验公式
|
||||||
@@ -91,6 +88,9 @@ class Electron():
|
|||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
return hash(self.content)
|
return hash(self.content)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def placeholder():
|
||||||
|
return Electron("电子对象样例内容", {})
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def import_from_file(path: pathlib.Path):
|
def import_from_file(path: pathlib.Path):
|
||||||
@@ -127,6 +127,10 @@ class Nucleon():
|
|||||||
with open(path, 'w') as f:
|
with open(path, 'w') as f:
|
||||||
toml.dump(nucleon_list, f)
|
toml.dump(nucleon_list, f)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def placeholder():
|
||||||
|
return Nucleon("核子对象样例内容", {})
|
||||||
|
|
||||||
class AtomicFile():
|
class AtomicFile():
|
||||||
def __init__(self, path, type_="unknown"):
|
def __init__(self, path, type_="unknown"):
|
||||||
self.path = path
|
self.path = path
|
||||||
@@ -149,3 +153,9 @@ class AtomicFile():
|
|||||||
return ""
|
return ""
|
||||||
def get_len(self):
|
def get_len(self):
|
||||||
return len(self.datalist)
|
return len(self.datalist)
|
||||||
|
|
||||||
|
|
||||||
|
class Atom():
|
||||||
|
@staticmethod
|
||||||
|
def placeholder():
|
||||||
|
return (Electron.placeholder(), Nucleon.placeholder())
|
86
reactor.py
86
reactor.py
@@ -1 +1,85 @@
|
|||||||
class Reactor(tasked_num):
|
import typing
|
||||||
|
import particles as pt
|
||||||
|
|
||||||
|
class Reactor():
|
||||||
|
def __init__(self, nucleon_file: pt.AtomicFile, electron_file: pt.AtomicFile, tasked_num):
|
||||||
|
# 导入原子对象
|
||||||
|
self.reported = set()
|
||||||
|
self.nucleon_file = nucleon_file
|
||||||
|
self.electron_file = electron_file
|
||||||
|
self.tasked_num = tasked_num
|
||||||
|
self.atoms_new: typing.List[typing.Tuple[pt.Electron, pt.Nucleon]] = list()
|
||||||
|
self.atoms_review: typing.List[typing.Tuple[pt.Electron, pt.Nucleon]] = list()
|
||||||
|
for atom in zip(electron_file.datalist, nucleon_file.datalist):
|
||||||
|
if atom[0].is_activated == 0:
|
||||||
|
atom[0].is_activated = 1
|
||||||
|
self.atoms_new.append(atom)
|
||||||
|
else:
|
||||||
|
if atom[0].next_date <= pt.Aux.get_daystamp():
|
||||||
|
atom[0].last_date = pt.Aux.get_daystamp()
|
||||||
|
self.atoms_review.append(atom)
|
||||||
|
# 设置运行时
|
||||||
|
self.index: int
|
||||||
|
self.procession: list
|
||||||
|
self.failed: list
|
||||||
|
self.round_title: str
|
||||||
|
self.reported: set
|
||||||
|
self.current_atom: typing.Tuple[pt.Electron, pt.Nucleon]
|
||||||
|
self.round_set = 0
|
||||||
|
self.current_atom = pt.Atom.placeholder()
|
||||||
|
|
||||||
|
def set_round(self, title, procession):
|
||||||
|
self.round_set = 1
|
||||||
|
self.round_title = title
|
||||||
|
self.procession = procession
|
||||||
|
self.failed = list()
|
||||||
|
self.index = -1
|
||||||
|
|
||||||
|
def set_round_templated(self, stage):
|
||||||
|
titles = {
|
||||||
|
1: "复习模式",
|
||||||
|
2: "新记忆模式",
|
||||||
|
3: "总复习模式"
|
||||||
|
}
|
||||||
|
processions = {
|
||||||
|
1: self.atoms_review,
|
||||||
|
2: self.atoms_new,
|
||||||
|
3: list(set(self.atoms_new + self.atoms_review))
|
||||||
|
}
|
||||||
|
ret = 1
|
||||||
|
if stage == 1 and len(processions[1]) == 0:
|
||||||
|
stage = 2
|
||||||
|
ret = 2
|
||||||
|
self.set_round(title=titles[stage], procession=processions[stage])
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def forward(self, step = 1):
|
||||||
|
if self.index + step >= len(self.procession):
|
||||||
|
if len(self.failed) > 0:
|
||||||
|
self.procession = self.failed
|
||||||
|
self.index = -1
|
||||||
|
self.forward(step)
|
||||||
|
if "- 额外复习" not in self.round_title:
|
||||||
|
self.round_title += " - 额外复习"
|
||||||
|
self.failed = list()
|
||||||
|
return 1 # 自动重定向到 failed
|
||||||
|
else:
|
||||||
|
self.round_set = 0
|
||||||
|
return -1 # 此轮已完成
|
||||||
|
self.index += step
|
||||||
|
self.current_atom = self.procession[self.index]
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
self.nucleon_file.save()
|
||||||
|
self.electron_file.save()
|
||||||
|
|
||||||
|
def report(self, atom, quality):
|
||||||
|
if atom[0] not in self.reported:
|
||||||
|
atom[0].revisor(quality)
|
||||||
|
self.reported.add(atom[0])
|
||||||
|
if quality <= 3:
|
||||||
|
self.failed.append(atom)
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
return 0
|
3
serve.py
Normal file
3
serve.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from webshare import server
|
||||||
|
server = server.Server("python3 main.py", title="辅助记忆程序", host="0.0.0.0")
|
||||||
|
server.serve()
|
0
webshare/__init__.py
Normal file
0
webshare/__init__.py
Normal file
BIN
webshare/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
webshare/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
webshare/__pycache__/_binary_encode.cpython-313.pyc
Normal file
BIN
webshare/__pycache__/_binary_encode.cpython-313.pyc
Normal file
Binary file not shown.
BIN
webshare/__pycache__/app_service.cpython-313.pyc
Normal file
BIN
webshare/__pycache__/app_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
webshare/__pycache__/download_manager.cpython-313.pyc
Normal file
BIN
webshare/__pycache__/download_manager.cpython-313.pyc
Normal file
Binary file not shown.
BIN
webshare/__pycache__/server.cpython-313.pyc
Normal file
BIN
webshare/__pycache__/server.cpython-313.pyc
Normal file
Binary file not shown.
325
webshare/_binary_encode.py
Normal file
325
webshare/_binary_encode.py
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
"""
|
||||||
|
An encoding / decoding format suitable for serializing data structures to binary.
|
||||||
|
|
||||||
|
This is based on https://en.wikipedia.org/wiki/Bencode with some extensions.
|
||||||
|
|
||||||
|
The following data types may be encoded:
|
||||||
|
|
||||||
|
- None
|
||||||
|
- int
|
||||||
|
- bool
|
||||||
|
- bytes
|
||||||
|
- str
|
||||||
|
- list
|
||||||
|
- tuple
|
||||||
|
- dict
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
class DecodeError(Exception):
|
||||||
|
"""A problem decoding data."""
|
||||||
|
|
||||||
|
|
||||||
|
def dump(data: object) -> bytes:
|
||||||
|
"""Encodes a data structure in to bytes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Data structure
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A byte string encoding the data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def encode_none(_datum: None) -> bytes:
|
||||||
|
"""
|
||||||
|
Encodes a None value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datum: Always None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None encoded.
|
||||||
|
"""
|
||||||
|
return b"N"
|
||||||
|
|
||||||
|
def encode_bool(datum: bool) -> bytes:
|
||||||
|
"""
|
||||||
|
Encode a boolean value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datum: The boolean value to encode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The encoded bytes.
|
||||||
|
"""
|
||||||
|
return b"T" if datum else b"F"
|
||||||
|
|
||||||
|
def encode_int(datum: int) -> bytes:
|
||||||
|
"""
|
||||||
|
Encode an integer value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datum: The integer value to encode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The encoded bytes.
|
||||||
|
"""
|
||||||
|
return b"i%ie" % datum
|
||||||
|
|
||||||
|
def encode_bytes(datum: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Encode a bytes value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datum: The bytes value to encode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The encoded bytes.
|
||||||
|
"""
|
||||||
|
return b"%i:%s" % (len(datum), datum)
|
||||||
|
|
||||||
|
def encode_string(datum: str) -> bytes:
|
||||||
|
"""
|
||||||
|
Encode a string value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datum: The string value to encode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The encoded bytes.
|
||||||
|
"""
|
||||||
|
encoded_data = datum.encode("utf-8")
|
||||||
|
return b"s%i:%s" % (len(encoded_data), encoded_data)
|
||||||
|
|
||||||
|
def encode_list(datum: list) -> bytes:
|
||||||
|
"""
|
||||||
|
Encode a list value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datum: The list value to encode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The encoded bytes.
|
||||||
|
"""
|
||||||
|
return b"l%se" % b"".join(encode(element) for element in datum)
|
||||||
|
|
||||||
|
def encode_tuple(datum: tuple) -> bytes:
|
||||||
|
"""
|
||||||
|
Encode a tuple value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datum: The tuple value to encode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The encoded bytes.
|
||||||
|
"""
|
||||||
|
return b"t%se" % b"".join(encode(element) for element in datum)
|
||||||
|
|
||||||
|
def encode_dict(datum: dict) -> bytes:
|
||||||
|
"""
|
||||||
|
Encode a dictionary value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datum: The dictionary value to encode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The encoded bytes.
|
||||||
|
"""
|
||||||
|
return b"d%se" % b"".join(
|
||||||
|
b"%s%s" % (encode(key), encode(value)) for key, value in datum.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
ENCODERS: dict[type, Callable[[Any], Any]] = {
|
||||||
|
type(None): encode_none,
|
||||||
|
bool: encode_bool,
|
||||||
|
int: encode_int,
|
||||||
|
bytes: encode_bytes,
|
||||||
|
str: encode_string,
|
||||||
|
list: encode_list,
|
||||||
|
tuple: encode_tuple,
|
||||||
|
dict: encode_dict,
|
||||||
|
}
|
||||||
|
|
||||||
|
def encode(datum: object) -> bytes:
|
||||||
|
"""Recursively encode data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datum: Data suitable for encoding.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If `datum` is not one of the supported types.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded data bytes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
decoder = ENCODERS[type(datum)]
|
||||||
|
except KeyError:
|
||||||
|
raise TypeError("Can't encode {datum!r}") from None
|
||||||
|
return decoder(datum)
|
||||||
|
|
||||||
|
return encode(data)
|
||||||
|
|
||||||
|
|
||||||
|
def load(encoded: bytes) -> object:
|
||||||
|
"""Load an encoded data structure from bytes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encoded: Encoded data in bytes.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DecodeError: If an error was encountered decoding the string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decoded data.
|
||||||
|
"""
|
||||||
|
if not isinstance(encoded, bytes):
|
||||||
|
raise TypeError("must be bytes")
|
||||||
|
max_position = len(encoded)
|
||||||
|
position = 0
|
||||||
|
|
||||||
|
def get_byte() -> bytes:
|
||||||
|
"""Get an encoded byte and advance position.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DecodeError: If the end of the data was reached
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A bytes object with a single byte.
|
||||||
|
"""
|
||||||
|
nonlocal position
|
||||||
|
if position >= max_position:
|
||||||
|
raise DecodeError("More data expected")
|
||||||
|
character = encoded[position : position + 1]
|
||||||
|
position += 1
|
||||||
|
return character
|
||||||
|
|
||||||
|
def peek_byte() -> bytes:
|
||||||
|
"""Get the byte at the current position, but don't advance position.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A bytes object with a single byte.
|
||||||
|
"""
|
||||||
|
return encoded[position : position + 1]
|
||||||
|
|
||||||
|
def get_bytes(size: int) -> bytes:
|
||||||
|
"""Get a number of bytes of encode data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Number of bytes to retrieve.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DecodeError: If there aren't enough bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A bytes object.
|
||||||
|
"""
|
||||||
|
nonlocal position
|
||||||
|
bytes_data = encoded[position : position + size]
|
||||||
|
if len(bytes_data) != size:
|
||||||
|
raise DecodeError(b"Missing bytes in {bytes_data!r}")
|
||||||
|
position += size
|
||||||
|
return bytes_data
|
||||||
|
|
||||||
|
def decode_int() -> int:
|
||||||
|
"""Decode an int from the encoded data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An integer.
|
||||||
|
"""
|
||||||
|
int_bytes = b""
|
||||||
|
while (byte := get_byte()) != b"e":
|
||||||
|
int_bytes += byte
|
||||||
|
return int(int_bytes)
|
||||||
|
|
||||||
|
def decode_bytes(size_bytes: bytes) -> bytes:
|
||||||
|
"""Decode a bytes string from the encoded data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A bytes object.
|
||||||
|
"""
|
||||||
|
while (byte := get_byte()) != b":":
|
||||||
|
size_bytes += byte
|
||||||
|
bytes_string = get_bytes(int(size_bytes))
|
||||||
|
return bytes_string
|
||||||
|
|
||||||
|
def decode_string() -> str:
|
||||||
|
"""Decode a (utf-8 encoded) string from the encoded data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A string.
|
||||||
|
"""
|
||||||
|
size_bytes = b""
|
||||||
|
while (byte := get_byte()) != b":":
|
||||||
|
size_bytes += byte
|
||||||
|
bytes_string = get_bytes(int(size_bytes))
|
||||||
|
decoded_string = bytes_string.decode("utf-8", errors="replace")
|
||||||
|
return decoded_string
|
||||||
|
|
||||||
|
def decode_list() -> list[object]:
|
||||||
|
"""Decode a list.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of data.
|
||||||
|
"""
|
||||||
|
elements: list[object] = []
|
||||||
|
add_element = elements.append
|
||||||
|
while peek_byte() != b"e":
|
||||||
|
add_element(decode())
|
||||||
|
get_byte()
|
||||||
|
return elements
|
||||||
|
|
||||||
|
def decode_tuple() -> tuple[object, ...]:
|
||||||
|
"""Decode a tuple.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of decoded data.
|
||||||
|
"""
|
||||||
|
elements: list[object] = []
|
||||||
|
add_element = elements.append
|
||||||
|
while peek_byte() != b"e":
|
||||||
|
add_element(decode())
|
||||||
|
get_byte()
|
||||||
|
return tuple(elements)
|
||||||
|
|
||||||
|
def decode_dict() -> dict[object, object]:
|
||||||
|
"""Decode a dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict of decoded data.
|
||||||
|
"""
|
||||||
|
elements: dict[object, object] = {}
|
||||||
|
add_element = elements.__setitem__
|
||||||
|
while peek_byte() != b"e":
|
||||||
|
add_element(decode(), decode())
|
||||||
|
get_byte()
|
||||||
|
return elements
|
||||||
|
|
||||||
|
DECODERS = {
|
||||||
|
b"i": decode_int,
|
||||||
|
b"s": decode_string,
|
||||||
|
b"l": decode_list,
|
||||||
|
b"t": decode_tuple,
|
||||||
|
b"d": decode_dict,
|
||||||
|
b"T": lambda: True,
|
||||||
|
b"F": lambda: False,
|
||||||
|
b"N": lambda: None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def decode() -> object:
|
||||||
|
"""Recursively decode data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decoded data.
|
||||||
|
"""
|
||||||
|
decoder = DECODERS.get(initial := get_byte(), None)
|
||||||
|
if decoder is None:
|
||||||
|
return decode_bytes(initial)
|
||||||
|
return decoder()
|
||||||
|
|
||||||
|
return decode()
|
349
webshare/app_service.py
Normal file
349
webshare/app_service.py
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Awaitable, Callable, Literal
|
||||||
|
from asyncio.subprocess import Process
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from importlib.metadata import version
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from webshare.download_manager import DownloadManager
|
||||||
|
from webshare._binary_encode import load as binary_load
|
||||||
|
|
||||||
|
log = logging.getLogger("textual-serve")
|
||||||
|
|
||||||
|
|
||||||
|
class AppService:
|
||||||
|
"""Creates and manages a single Textual app subprocess.
|
||||||
|
|
||||||
|
When a user connects to the websocket in their browser, a new AppService
|
||||||
|
instance is created to manage the corresponding Textual app process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
*,
|
||||||
|
write_bytes: Callable[[bytes], Awaitable[None]],
|
||||||
|
write_str: Callable[[str], Awaitable[None]],
|
||||||
|
close: Callable[[], Awaitable[None]],
|
||||||
|
download_manager: DownloadManager,
|
||||||
|
debug: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.app_service_id: str = uuid.uuid4().hex
|
||||||
|
"""The unique ID of this running app service."""
|
||||||
|
self.command = command
|
||||||
|
"""The command to launch the Textual app subprocess."""
|
||||||
|
self.remote_write_bytes = write_bytes
|
||||||
|
"""Write bytes to the client browser websocket."""
|
||||||
|
self.remote_write_str = write_str
|
||||||
|
"""Write string to the client browser websocket."""
|
||||||
|
self.remote_close = close
|
||||||
|
"""Close the client browser websocket."""
|
||||||
|
self.debug = debug
|
||||||
|
"""Enable/disable debug mode."""
|
||||||
|
|
||||||
|
self._process: Process | None = None
|
||||||
|
self._task: asyncio.Task[None] | None = None
|
||||||
|
self._stdin: asyncio.StreamWriter | None = None
|
||||||
|
self._exit_event = asyncio.Event()
|
||||||
|
self._download_manager = download_manager
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stdin(self) -> asyncio.StreamWriter:
|
||||||
|
"""The processes standard input stream."""
|
||||||
|
assert self._stdin is not None
|
||||||
|
return self._stdin
|
||||||
|
|
||||||
|
def _build_environment(self, width: int = 80, height: int = 24) -> dict[str, str]:
|
||||||
|
"""Build an environment dict for the App subprocess.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Initial width.
|
||||||
|
height: Initial height.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A environment dict.
|
||||||
|
"""
|
||||||
|
environment = dict(os.environ.copy())
|
||||||
|
environment["TEXTUAL_DRIVER"] = "textual.drivers.web_driver:WebDriver"
|
||||||
|
environment["TEXTUAL_FPS"] = "60"
|
||||||
|
environment["TEXTUAL_COLOR_SYSTEM"] = "truecolor"
|
||||||
|
environment["TERM_PROGRAM"] = "textual"
|
||||||
|
environment["TERM_PROGRAM_VERSION"] = version("textual-serve")
|
||||||
|
environment["COLUMNS"] = str(width)
|
||||||
|
environment["ROWS"] = str(height)
|
||||||
|
if self.debug:
|
||||||
|
environment["TEXTUAL"] = "debug,devtools"
|
||||||
|
environment["TEXTUAL_LOG"] = "textual.log"
|
||||||
|
return environment
|
||||||
|
|
||||||
|
async def _open_app_process(self, width: int = 80, height: int = 24) -> Process:
|
||||||
|
"""Open a process to run the app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Width of the terminal.
|
||||||
|
height: height of the terminal.
|
||||||
|
"""
|
||||||
|
environment = self._build_environment(width=width, height=height)
|
||||||
|
self._process = process = await asyncio.create_subprocess_shell(
|
||||||
|
self.command,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=environment,
|
||||||
|
)
|
||||||
|
assert process.stdin is not None
|
||||||
|
self._stdin = process.stdin
|
||||||
|
|
||||||
|
return process
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def encode_packet(cls, packet_type: Literal[b"D", b"M"], payload: bytes) -> bytes:
|
||||||
|
"""Encode a packet.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
packet_type: The packet type (b"D" for data or b"M" for meta)
|
||||||
|
payload: The payload.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Data as bytes.
|
||||||
|
"""
|
||||||
|
return b"%s%s%s" % (packet_type, len(payload).to_bytes(4, "big"), payload)
|
||||||
|
|
||||||
|
async def send_bytes(self, data: bytes) -> bool:
|
||||||
|
"""Send bytes to process.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Data to send.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the data was sent, otherwise False.
|
||||||
|
"""
|
||||||
|
stdin = self.stdin
|
||||||
|
try:
|
||||||
|
stdin.write(self.encode_packet(b"D", data))
|
||||||
|
except RuntimeError:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await stdin.drain()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def send_meta(self, data: dict[str, str | None | int | bool]) -> bool:
|
||||||
|
"""Send meta information to process.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Meta dict to send.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the data was sent, otherwise False.
|
||||||
|
"""
|
||||||
|
stdin = self.stdin
|
||||||
|
data_bytes = json.dumps(data).encode("utf-8")
|
||||||
|
try:
|
||||||
|
stdin.write(self.encode_packet(b"M", data_bytes))
|
||||||
|
except RuntimeError:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await stdin.drain()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def set_terminal_size(self, width: int, height: int) -> None:
|
||||||
|
"""Tell the process about the new terminal size.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Width of terminal in cells.
|
||||||
|
height: Height of terminal in cells.
|
||||||
|
"""
|
||||||
|
await self.send_meta(
|
||||||
|
{
|
||||||
|
"type": "resize",
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def blur(self) -> None:
|
||||||
|
"""Send an (app) blur to the process."""
|
||||||
|
await self.send_meta({"type": "blur"})
|
||||||
|
|
||||||
|
async def focus(self) -> None:
|
||||||
|
"""Send an (app) focus to the process."""
|
||||||
|
await self.send_meta({"type": "focus"})
|
||||||
|
|
||||||
|
async def start(self, width: int, height: int) -> None:
|
||||||
|
await self._open_app_process(width, height)
|
||||||
|
self._task = asyncio.create_task(self.run())
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the process and wait for it to complete."""
|
||||||
|
if self._task is not None:
|
||||||
|
await self._download_manager.cancel_app_downloads(
|
||||||
|
app_service_id=self.app_service_id
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.send_meta({"type": "quit"})
|
||||||
|
await self._task
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
"""Run the Textual app process.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
Do not call this manually, use `start`.
|
||||||
|
|
||||||
|
"""
|
||||||
|
META = b"M"
|
||||||
|
DATA = b"D"
|
||||||
|
PACKED = b"P"
|
||||||
|
|
||||||
|
assert self._process is not None
|
||||||
|
process = self._process
|
||||||
|
|
||||||
|
stdout = process.stdout
|
||||||
|
stderr = process.stderr
|
||||||
|
assert stdout is not None
|
||||||
|
assert stderr is not None
|
||||||
|
|
||||||
|
stderr_data = io.BytesIO()
|
||||||
|
|
||||||
|
async def read_stderr() -> None:
|
||||||
|
"""Task to read stderr."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await stderr.read(1024 * 4)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
stderr_data.write(data)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
stderr_task = asyncio.create_task(read_stderr())
|
||||||
|
|
||||||
|
try:
|
||||||
|
ready = False
|
||||||
|
# Wait for prelude text, so we know it is a Textual app
|
||||||
|
for _ in range(10):
|
||||||
|
if not (line := await stdout.readline()):
|
||||||
|
break
|
||||||
|
if line == b"__GANGLION__\n":
|
||||||
|
ready = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not ready:
|
||||||
|
log.error("Application failed to start")
|
||||||
|
if error_text := stderr_data.getvalue():
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.stdout.write(error_text.decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
readexactly = stdout.readexactly
|
||||||
|
int_from_bytes = int.from_bytes
|
||||||
|
while True:
|
||||||
|
type_bytes = await readexactly(1)
|
||||||
|
size_bytes = await readexactly(4)
|
||||||
|
size = int_from_bytes(size_bytes, "big")
|
||||||
|
payload = await readexactly(size)
|
||||||
|
if type_bytes == DATA:
|
||||||
|
await self.on_data(payload)
|
||||||
|
elif type_bytes == META:
|
||||||
|
await self.on_meta(payload)
|
||||||
|
elif type_bytes == PACKED:
|
||||||
|
await self.on_packed(payload)
|
||||||
|
|
||||||
|
except asyncio.IncompleteReadError:
|
||||||
|
pass
|
||||||
|
except ConnectionResetError:
|
||||||
|
pass
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
finally:
|
||||||
|
stderr_task.cancel()
|
||||||
|
await stderr_task
|
||||||
|
|
||||||
|
if error_text := stderr_data.getvalue():
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.stdout.write(error_text.decode("utf-8", "replace"))
|
||||||
|
|
||||||
|
async def on_data(self, payload: bytes) -> None:
|
||||||
|
"""Called when there is data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Data received from process.
|
||||||
|
"""
|
||||||
|
await self.remote_write_bytes(payload)
|
||||||
|
|
||||||
|
async def on_meta(self, data: bytes) -> None:
|
||||||
|
"""Called when there is a meta packet sent from the running app process.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Encoded meta data.
|
||||||
|
"""
|
||||||
|
meta_data: dict[str, object] = json.loads(data)
|
||||||
|
meta_type = meta_data["type"]
|
||||||
|
|
||||||
|
if meta_type == "exit":
|
||||||
|
await self.remote_close()
|
||||||
|
elif meta_type == "open_url":
|
||||||
|
payload = json.dumps(
|
||||||
|
[
|
||||||
|
"open_url",
|
||||||
|
{
|
||||||
|
"url": meta_data["url"],
|
||||||
|
"new_tab": meta_data["new_tab"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await self.remote_write_str(payload)
|
||||||
|
elif meta_type == "deliver_file_start":
|
||||||
|
log.debug("deliver_file_start, %s", meta_data)
|
||||||
|
try:
|
||||||
|
# Record this delivery key as available for download.
|
||||||
|
delivery_key = str(meta_data["key"])
|
||||||
|
await self._download_manager.create_download(
|
||||||
|
app_service=self,
|
||||||
|
delivery_key=delivery_key,
|
||||||
|
file_name=Path(meta_data["path"]).name,
|
||||||
|
open_method=meta_data["open_method"],
|
||||||
|
mime_type=meta_data["mime_type"],
|
||||||
|
encoding=meta_data["encoding"],
|
||||||
|
name=meta_data.get("name", None),
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
log.error("Missing key in `deliver_file_start` meta packet")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# Tell the browser front-end about the new delivery key,
|
||||||
|
# so that it may hit the "/download/{key}" endpoint
|
||||||
|
# to start the download.
|
||||||
|
json_string = json.dumps(["deliver_file_start", delivery_key])
|
||||||
|
await self.remote_write_str(json_string)
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
f"Unknown meta type: {meta_type!r}. You may need to update `textual-serve`."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_packed(self, payload: bytes) -> None:
|
||||||
|
"""Called when there is a packed packet sent from the running app process.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Encoded packed data.
|
||||||
|
"""
|
||||||
|
unpacked = binary_load(payload)
|
||||||
|
if unpacked[0] == "deliver_chunk":
|
||||||
|
# If we receive a chunk, hand it to the download manager to
|
||||||
|
# handle distribution to the browser.
|
||||||
|
_, delivery_key, chunk = unpacked
|
||||||
|
await self._download_manager.chunk_received(delivery_key, chunk)
|
197
webshare/download_manager.py
Normal file
197
webshare/download_manager.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import logging
|
||||||
|
from typing import AsyncGenerator, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from webshare.app_service import AppService
|
||||||
|
|
||||||
|
log = logging.getLogger("textual-serve")
|
||||||
|
|
||||||
|
DOWNLOAD_TIMEOUT = 4
|
||||||
|
DOWNLOAD_CHUNK_SIZE = 1024 * 64 # 64 KB
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Download:
|
||||||
|
app_service: "AppService"
|
||||||
|
"""The app service that the download is associated with."""
|
||||||
|
|
||||||
|
delivery_key: str
|
||||||
|
"""Key which identifies the download."""
|
||||||
|
|
||||||
|
file_name: str
|
||||||
|
"""The name of the file to download. This will be used to set
|
||||||
|
the Content-Disposition filename."""
|
||||||
|
|
||||||
|
open_method: str
|
||||||
|
"""The method to open the file with. "browser" or "download"."""
|
||||||
|
|
||||||
|
mime_type: str
|
||||||
|
"""The mime type of the content."""
|
||||||
|
|
||||||
|
encoding: str | None = None
|
||||||
|
"""The encoding of the content.
|
||||||
|
Will be None if the content is binary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str | None = None
|
||||||
|
"""Optional name set bt the client."""
|
||||||
|
|
||||||
|
incoming_chunks: asyncio.Queue[bytes | None] = field(default_factory=asyncio.Queue)
|
||||||
|
"""A queue of incoming chunks for the download.
|
||||||
|
Chunks are sent from the app service to the download handler
|
||||||
|
via this queue."""
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadManager:
|
||||||
|
"""Class which manages downloads for the server.
|
||||||
|
|
||||||
|
Serves as the link between the web server and app processes during downloads.
|
||||||
|
|
||||||
|
A single server has a single download manager, which manages all downloads for all
|
||||||
|
running app processes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._active_downloads: dict[str, Download] = {}
|
||||||
|
"""A dictionary of active downloads.
|
||||||
|
|
||||||
|
When a delivery key is received in a meta packet, it is added to this set.
|
||||||
|
When the user hits the "/download/{key}" endpoint, we ensure the key is in
|
||||||
|
this set and start the download by requesting chunks from the app process.
|
||||||
|
|
||||||
|
When the download is complete, the app process sends a "deliver_file_end"
|
||||||
|
meta packet, and we remove the key from this set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def create_download(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
app_service: "AppService",
|
||||||
|
delivery_key: str,
|
||||||
|
file_name: str,
|
||||||
|
open_method: str,
|
||||||
|
mime_type: str,
|
||||||
|
encoding: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Prepare for a new download.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_service: The app service to start the download for.
|
||||||
|
delivery_key: The delivery key to start the download for.
|
||||||
|
file_name: The name of the file to download.
|
||||||
|
open_method: The method to open the file with.
|
||||||
|
mime_type: The mime type of the content.
|
||||||
|
encoding: The encoding of the content or None if the content is binary.
|
||||||
|
"""
|
||||||
|
self._active_downloads[delivery_key] = Download(
|
||||||
|
app_service,
|
||||||
|
delivery_key,
|
||||||
|
file_name,
|
||||||
|
open_method,
|
||||||
|
mime_type,
|
||||||
|
encoding,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def download(self, delivery_key: str) -> AsyncGenerator[bytes, None]:
|
||||||
|
"""Download a file from the given app service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delivery_key: The delivery key to download.
|
||||||
|
"""
|
||||||
|
|
||||||
|
app_service = await self._get_app_service(delivery_key)
|
||||||
|
download = self._active_downloads[delivery_key]
|
||||||
|
incoming_chunks = download.incoming_chunks
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Request a chunk from the app service.
|
||||||
|
send_result = await app_service.send_meta(
|
||||||
|
{
|
||||||
|
"type": "deliver_chunk_request",
|
||||||
|
"key": delivery_key,
|
||||||
|
"size": DOWNLOAD_CHUNK_SIZE,
|
||||||
|
"name": download.name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not send_result:
|
||||||
|
log.warning(
|
||||||
|
"Download {delivery_key!r} failed to request chunk from app service"
|
||||||
|
)
|
||||||
|
del self._active_downloads[delivery_key]
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
chunk = await asyncio.wait_for(incoming_chunks.get(), DOWNLOAD_TIMEOUT)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.warning(
|
||||||
|
"Download %r failed to receive chunk from app service within %r seconds",
|
||||||
|
delivery_key,
|
||||||
|
DOWNLOAD_TIMEOUT,
|
||||||
|
)
|
||||||
|
chunk = None
|
||||||
|
|
||||||
|
if not chunk:
|
||||||
|
# Empty chunk - the app process has finished sending the file
|
||||||
|
# or the download has been cancelled.
|
||||||
|
incoming_chunks.task_done()
|
||||||
|
del self._active_downloads[delivery_key]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
incoming_chunks.task_done()
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
async def chunk_received(self, delivery_key: str, chunk: bytes | str) -> None:
|
||||||
|
"""Handle a chunk received from the app service for a download.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delivery_key: The delivery key that the chunk was received for.
|
||||||
|
chunk: The chunk that was received.
|
||||||
|
"""
|
||||||
|
|
||||||
|
download = self._active_downloads.get(delivery_key)
|
||||||
|
if not download:
|
||||||
|
# The download may have been cancelled - e.g. the websocket
|
||||||
|
# was closed before the download could complete.
|
||||||
|
log.debug("Chunk received for cancelled download %r", delivery_key)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(chunk, str):
|
||||||
|
chunk = chunk.encode(download.encoding or "utf-8")
|
||||||
|
await download.incoming_chunks.put(chunk)
|
||||||
|
|
||||||
|
async def _get_app_service(self, delivery_key: str) -> "AppService":
|
||||||
|
"""Get the app service that the given delivery key is linked to.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delivery_key: The delivery key to get the app service for.
|
||||||
|
"""
|
||||||
|
for key in self._active_downloads.keys():
|
||||||
|
if key == delivery_key:
|
||||||
|
return self._active_downloads[key].app_service
|
||||||
|
else:
|
||||||
|
raise ValueError(f"No active download for delivery key {delivery_key!r}")
|
||||||
|
|
||||||
|
async def get_download_metadata(self, delivery_key: str) -> Download:
|
||||||
|
"""Get the metadata for a download.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delivery_key: The delivery key to get the metadata for.
|
||||||
|
"""
|
||||||
|
return self._active_downloads[delivery_key]
|
||||||
|
|
||||||
|
async def cancel_app_downloads(self, app_service_id: str) -> None:
|
||||||
|
"""Cancel all downloads for the given app service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_service_id: The app service ID to cancel downloads for.
|
||||||
|
"""
|
||||||
|
for download in self._active_downloads.values():
|
||||||
|
if download.app_service.app_service_id == app_service_id:
|
||||||
|
await download.incoming_chunks.put(None)
|
0
webshare/py.typed
Normal file
0
webshare/py.typed
Normal file
350
webshare/server.py
Normal file
350
webshare/server.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp_jinja2
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp import WSMsgType
|
||||||
|
from aiohttp.web_runner import GracefulExit
|
||||||
|
import jinja2
|
||||||
|
|
||||||
|
from importlib.metadata import version
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.logging import RichHandler
|
||||||
|
from rich.highlighter import RegexHighlighter
|
||||||
|
|
||||||
|
from webshare.download_manager import DownloadManager
|
||||||
|
|
||||||
|
from .app_service import AppService
|
||||||
|
|
||||||
|
log = logging.getLogger("textual-serve")
|
||||||
|
|
||||||
|
LOGO = r"""[bold magenta]___ ____ _ _ ___ _ _ ____ _ ____ ____ ____ _ _ ____
|
||||||
|
| |___ \/ | | | |__| | __ [__ |___ |__/ | | |___
|
||||||
|
| |___ _/\_ | |__| | | |___ ___] |___ | \ \/ |___ [not bold]VVVVV
|
||||||
|
""".replace("VVVVV", f"v{version('textual-serve')}")
|
||||||
|
|
||||||
|
|
||||||
|
WINDOWS = sys.platform == "WINDOWS"
|
||||||
|
|
||||||
|
|
||||||
|
class LogHighlighter(RegexHighlighter):
|
||||||
|
base_style = "repr."
|
||||||
|
highlights = [
|
||||||
|
r"(?P<number>(?<!\w)\-?[0-9]+\.?[0-9]*(e[-+]?\d+?)?\b|0x[0-9a-fA-F]*)",
|
||||||
|
r"(?P<path>\[.*?\])",
|
||||||
|
r"(?<![\\\w])(?P<str>b?'''.*?(?<!\\)'''|b?'.*?(?<!\\)'|b?\"\"\".*?(?<!\\)\"\"\"|b?\".*?(?<!\\)\")",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def to_int(value: str, default: int) -> int:
|
||||||
|
"""Convert to an integer, or return a default if that's not possible.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
number: A string possibly containing a decimal.
|
||||||
|
default: Default value if value can't be decoded.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Integer.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class Server:
|
||||||
|
"""Serve a Textual app."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
host: str = "localhost",
|
||||||
|
port: int = 8000,
|
||||||
|
title: str | None = None,
|
||||||
|
public_url: str | None = None,
|
||||||
|
statics_path: str | os.PathLike = "./static",
|
||||||
|
templates_path: str | os.PathLike = "./templates",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_factory: A callable that returns a new App instance.
|
||||||
|
host: Host of web application.
|
||||||
|
port: Port for server.
|
||||||
|
statics_path: Path to statics folder. May be absolute or relative to server.py.
|
||||||
|
templates_path" Path to templates folder. May be absolute or relative to server.py.
|
||||||
|
"""
|
||||||
|
self.command = command
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.title = title or command
|
||||||
|
self.debug = False
|
||||||
|
|
||||||
|
if public_url is None:
|
||||||
|
if self.port == 80:
|
||||||
|
self.public_url = f"http://{self.host}"
|
||||||
|
elif self.port == 443:
|
||||||
|
self.public_url = f"https://{self.host}"
|
||||||
|
else:
|
||||||
|
self.public_url = f"http://{self.host}:{self.port}"
|
||||||
|
else:
|
||||||
|
self.public_url = public_url
|
||||||
|
|
||||||
|
base_path = (Path(__file__) / "../").resolve().absolute()
|
||||||
|
self.statics_path = base_path / statics_path
|
||||||
|
self.templates_path = base_path / templates_path
|
||||||
|
self.console = Console()
|
||||||
|
self.download_manager = DownloadManager()
|
||||||
|
|
||||||
|
def initialize_logging(self) -> None:
|
||||||
|
"""Initialize logging.
|
||||||
|
|
||||||
|
May be overridden in a subclass.
|
||||||
|
"""
|
||||||
|
FORMAT = "%(message)s"
|
||||||
|
logging.basicConfig(
|
||||||
|
level="DEBUG" if self.debug else "INFO",
|
||||||
|
format=FORMAT,
|
||||||
|
datefmt="[%X]",
|
||||||
|
handlers=[
|
||||||
|
RichHandler(
|
||||||
|
show_path=False,
|
||||||
|
show_time=False,
|
||||||
|
rich_tracebacks=True,
|
||||||
|
tracebacks_show_locals=True,
|
||||||
|
highlighter=LogHighlighter(),
|
||||||
|
console=self.console,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def request_exit(self) -> None:
|
||||||
|
"""Gracefully exit the app."""
|
||||||
|
raise GracefulExit()
|
||||||
|
|
||||||
|
async def _make_app(self) -> web.Application:
|
||||||
|
"""Make the aiohttp web.Application.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New aiohttp web application.
|
||||||
|
"""
|
||||||
|
app = web.Application()
|
||||||
|
|
||||||
|
aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(self.templates_path))
|
||||||
|
|
||||||
|
ROUTES = [
|
||||||
|
web.get("/", self.handle_index, name="index"),
|
||||||
|
web.get("/ws", self.handle_websocket, name="websocket"),
|
||||||
|
web.get("/download/{key}", self.handle_download, name="download"),
|
||||||
|
web.static("/static", self.statics_path, show_index=True, name="static"),
|
||||||
|
]
|
||||||
|
app.add_routes(ROUTES)
|
||||||
|
|
||||||
|
app.on_startup.append(self.on_startup)
|
||||||
|
app.on_shutdown.append(self.on_shutdown)
|
||||||
|
return app
|
||||||
|
|
||||||
|
async def handle_download(self, request: web.Request) -> web.StreamResponse:
|
||||||
|
"""Handle a download request."""
|
||||||
|
key = request.match_info["key"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
download_meta = await self.download_manager.get_download_metadata(key)
|
||||||
|
except KeyError:
|
||||||
|
raise web.HTTPNotFound(text=f"Download with key {key!r} not found")
|
||||||
|
|
||||||
|
response = web.StreamResponse()
|
||||||
|
mime_type = download_meta.mime_type
|
||||||
|
|
||||||
|
content_type = mime_type
|
||||||
|
if download_meta.encoding:
|
||||||
|
content_type += f"; charset={download_meta.encoding}"
|
||||||
|
|
||||||
|
response.headers["Content-Type"] = content_type
|
||||||
|
disposition = (
|
||||||
|
"inline" if download_meta.open_method == "browser" else "attachment"
|
||||||
|
)
|
||||||
|
response.headers["Content-Disposition"] = (
|
||||||
|
f"{disposition}; filename={download_meta.file_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await response.prepare(request)
|
||||||
|
|
||||||
|
async for chunk in self.download_manager.download(key):
|
||||||
|
await response.write(chunk)
|
||||||
|
|
||||||
|
await response.write_eof()
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def on_shutdown(self, app: web.Application) -> None:
|
||||||
|
"""Called on shutdown.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: App instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def on_startup(self, app: web.Application) -> None:
|
||||||
|
"""Called on startup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: App instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.console.print(LOGO, highlight=False)
|
||||||
|
self.console.print(f"Serving {self.command!r} on {self.public_url}")
|
||||||
|
self.console.print("\n[cyan]Press Ctrl+C to quit")
|
||||||
|
|
||||||
|
def serve(self, debug: bool = False) -> None:
|
||||||
|
"""Serve the Textual application.
|
||||||
|
|
||||||
|
This will run a local webserver until it is closed with Ctrl+C
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.debug = debug
|
||||||
|
self.initialize_logging()
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
loop.add_signal_handler(signal.SIGINT, self.request_exit)
|
||||||
|
loop.add_signal_handler(signal.SIGTERM, self.request_exit)
|
||||||
|
except NotImplementedError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
log.info("Running in debug mode. You may use textual dev tools.")
|
||||||
|
|
||||||
|
web.run_app(
|
||||||
|
self._make_app(),
|
||||||
|
host=self.host,
|
||||||
|
port=self.port,
|
||||||
|
handle_signals=False,
|
||||||
|
loop=loop,
|
||||||
|
print=lambda *args: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@aiohttp_jinja2.template("app_index.html")
|
||||||
|
async def handle_index(self, request: web.Request) -> dict[str, Any]:
|
||||||
|
"""Serves the HTML for an app.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Template data.
|
||||||
|
"""
|
||||||
|
router = request.app.router
|
||||||
|
font_size = to_int(request.query.get("fontsize", "16"), 16)
|
||||||
|
|
||||||
|
def get_url(route: str, **args) -> str:
|
||||||
|
"""Get a URL from the aiohttp router."""
|
||||||
|
path = router[route].url_for(**args)
|
||||||
|
return f"{self.public_url}{path}"
|
||||||
|
|
||||||
|
def get_websocket_url(route: str, **args) -> str:
|
||||||
|
"""Get a URL with a websocket prefix."""
|
||||||
|
url = get_url(route, **args)
|
||||||
|
|
||||||
|
if self.public_url.startswith("https"):
|
||||||
|
return "wss:" + url.split(":", 1)[1]
|
||||||
|
else:
|
||||||
|
return "ws:" + url.split(":", 1)[1]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"font_size": font_size,
|
||||||
|
"app_websocket_url": get_websocket_url("websocket"),
|
||||||
|
}
|
||||||
|
context["config"] = {
|
||||||
|
"static": {
|
||||||
|
"url": get_url("static", filename="/").rstrip("/") + "/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
context["application"] = {
|
||||||
|
"name": self.title,
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
|
||||||
|
async def _process_messages(
|
||||||
|
self, websocket: web.WebSocketResponse, app_service: AppService
|
||||||
|
) -> None:
|
||||||
|
"""Process messages from the client browser websocket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
websocket: Websocket instance.
|
||||||
|
app_service: App service.
|
||||||
|
"""
|
||||||
|
TEXT = WSMsgType.TEXT
|
||||||
|
|
||||||
|
async for message in websocket:
|
||||||
|
if message.type != TEXT:
|
||||||
|
continue
|
||||||
|
envelope = message.json()
|
||||||
|
assert isinstance(envelope, list)
|
||||||
|
type_ = envelope[0]
|
||||||
|
if type_ == "stdin":
|
||||||
|
data = envelope[1]
|
||||||
|
await app_service.send_bytes(data.encode("utf-8"))
|
||||||
|
elif type_ == "resize":
|
||||||
|
data = envelope[1]
|
||||||
|
await app_service.set_terminal_size(data["width"], data["height"])
|
||||||
|
elif type_ == "ping":
|
||||||
|
data = envelope[1]
|
||||||
|
await websocket.send_json(["pong", data])
|
||||||
|
elif type_ == "blur":
|
||||||
|
await app_service.blur()
|
||||||
|
elif type_ == "focus":
|
||||||
|
await app_service.focus()
|
||||||
|
|
||||||
|
async def handle_websocket(self, request: web.Request) -> web.WebSocketResponse:
|
||||||
|
"""Handle the websocket that drives the remote process.
|
||||||
|
|
||||||
|
This is called when the browser connects to the websocket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Websocket response.
|
||||||
|
"""
|
||||||
|
websocket = web.WebSocketResponse(heartbeat=15)
|
||||||
|
|
||||||
|
width = to_int(request.query.get("width", "80"), 80)
|
||||||
|
height = to_int(request.query.get("height", "24"), 24)
|
||||||
|
|
||||||
|
app_service: AppService | None = None
|
||||||
|
try:
|
||||||
|
await websocket.prepare(request)
|
||||||
|
app_service = AppService(
|
||||||
|
self.command,
|
||||||
|
write_bytes=websocket.send_bytes,
|
||||||
|
write_str=websocket.send_str,
|
||||||
|
close=websocket.close,
|
||||||
|
download_manager=self.download_manager,
|
||||||
|
debug=self.debug,
|
||||||
|
)
|
||||||
|
await app_service.start(width, height)
|
||||||
|
try:
|
||||||
|
await self._process_messages(websocket, app_service)
|
||||||
|
finally:
|
||||||
|
await app_service.stop()
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
except Exception as error:
|
||||||
|
log.exception(error)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if app_service is not None:
|
||||||
|
await app_service.stop()
|
||||||
|
|
||||||
|
return websocket
|
191
webshare/static/css/xterm.css
Normal file
191
webshare/static/css/xterm.css
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||||
|
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||||
|
* https://github.com/chjj/term.js
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*
|
||||||
|
* Originally forked from (with the author's permission):
|
||||||
|
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||||
|
* http://bellard.org/jslinux/
|
||||||
|
* Copyright (c) 2011 Fabrice Bellard
|
||||||
|
* The original design remains. The terminal itself
|
||||||
|
* has been extended to include xterm CSI codes, among
|
||||||
|
* other features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default styles for xterm.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
cursor: text;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.focus,
|
||||||
|
.xterm:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helpers {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
/**
|
||||||
|
* The z-index of the helpers must be higher than the canvases in order for
|
||||||
|
* IMEs to appear on top.
|
||||||
|
*/
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helper-textarea {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
left: -9999em;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: -5;
|
||||||
|
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view {
|
||||||
|
/* TODO: Composition position got messed up somewhere */
|
||||||
|
background: #000;
|
||||||
|
color: #FFF;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||||
|
background-color: #000;
|
||||||
|
overflow-y: scroll;
|
||||||
|
cursor: default;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen canvas {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-scroll-area {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-char-measure-element {
|
||||||
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -9999em;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.enable-mouse-events {
|
||||||
|
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.xterm-cursor-pointer,
|
||||||
|
.xterm .xterm-cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.column-select.focus {
|
||||||
|
/* Column selection mode */
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility,
|
||||||
|
.xterm .xterm-message {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .live-region {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-dim {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-underline-1 { text-decoration: underline; }
|
||||||
|
.xterm-underline-2 { text-decoration: double underline; }
|
||||||
|
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||||
|
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||||
|
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||||
|
|
||||||
|
.xterm-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||||
|
z-index: 6;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-overview-ruler {
|
||||||
|
z-index: 7;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-top {
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
BIN
webshare/static/fonts/RobotoMono-Italic-VariableFont_wght.ttf
Normal file
BIN
webshare/static/fonts/RobotoMono-Italic-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
webshare/static/fonts/RobotoMono-VariableFont_wght.ttf
Normal file
BIN
webshare/static/fonts/RobotoMono-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
webshare/static/images/background.png
Normal file
BIN
webshare/static/images/background.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 58 KiB |
1
webshare/static/js/textual.js
Normal file
1
webshare/static/js/textual.js
Normal file
File diff suppressed because one or more lines are too long
148
webshare/templates/app_index.html
Normal file
148
webshare/templates/app_index.html
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<link rel="stylesheet" href="{{ config.static.url }}css/xterm.css" />
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto%20Mono"/>
|
||||||
|
<script src="{{ config.static.url }}js/textual.js"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-container {
|
||||||
|
font-family: "Roboto Mono", menlo, monospace;
|
||||||
|
position: absolute;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.shade {
|
||||||
|
font-family: "Roboto Mono", menlo, monospace;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.intro {
|
||||||
|
font-family: "Roboto Mono", menlo, monospace;
|
||||||
|
width: 640px;
|
||||||
|
height: 240px;
|
||||||
|
z-index: 20;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
background-color: #000000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.-first-byte .intro-dialog,
|
||||||
|
body.-first-byte .intro-dialog .shade {
|
||||||
|
font-family: "Roboto Mono", menlo, monospace;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .textual-terminal {
|
||||||
|
font-family: "Roboto Mono", menlo, monospace;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.-first-byte .textual-terminal {
|
||||||
|
font-family: "Roboto Mono", menlo, monospace;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
body Button {
|
||||||
|
font-family: "Roboto Mono", menlo, monospace;
|
||||||
|
background-color: #000000;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closed-dialog {
|
||||||
|
font-family: "Roboto Mono", menlo, monospace;
|
||||||
|
opacity: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.-closed .closed-dialog {
|
||||||
|
font-family: "Roboto Mono", menlo, monospace;
|
||||||
|
opacity: 1;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#start {
|
||||||
|
font-family: "Roboto Mono", menlo, monospace;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#start.-delay {
|
||||||
|
font-family: "Roboto Mono", menlo, monospace;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
background-color: #000000;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
function getStartUrl() {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const params = new URLSearchParams(url.search);
|
||||||
|
params.delete("delay");
|
||||||
|
return url.pathname + "?" + params.toString();
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
const ping_url = document.body.dataset.pingurl;
|
||||||
|
if (ping_url) {
|
||||||
|
await fetch(ping_url, {
|
||||||
|
method: "GET",
|
||||||
|
mode: "no-cors",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.location.href = getStartUrl();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body data-pingurl="{{ ping_url }}">
|
||||||
|
<div class="dialog-container intro-dialog">
|
||||||
|
<div class="shade"></div>
|
||||||
|
<div class="intro">
|
||||||
|
<div>正加载命令行应用实例...<br><br>
|
||||||
|
确保使用现代浏览器并启用 JavaScript<br>
|
||||||
|
终端模拟器基于 XTerm.js<br>应用程序框架: Textual <br>
|
||||||
|
© Wang Zhiyu 2024-2025, 保留此实例的所有权</div>
|
||||||
|
<button type="button" onClick="refresh()" id="start">启动新实例</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-container closed-dialog">
|
||||||
|
<div class="shade"></div>
|
||||||
|
<div class="intro">
|
||||||
|
<div class="message">实例程序已终止</div>
|
||||||
|
<button type="button" onClick="refresh()">重新打开实例</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="terminal"
|
||||||
|
class="textual-terminal"
|
||||||
|
data-session-websocket-url="{{ app_websocket_url }}"
|
||||||
|
data-font-size="{{ font_size }}"
|
||||||
|
></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
Reference in New Issue
Block a user