diff --git a/docs/spec.rst b/docs/spec.rst index 3f06640b..3ac53a2b 100644 --- a/docs/spec.rst +++ b/docs/spec.rst @@ -395,6 +395,24 @@ The ``spec`` fields of ``download`` commands: * name: str, File name when downloading * content: str, File content in base64 encoding. +open_page +^^^^^^^^^^^^^^^ +Open new page + +The ``spec`` fields of ``new_page`` commands: + +* page_id: str, page id to be created +* new_window: bool, whether to open sub-page as new browser window or iframe + +close_page +^^^^^^^^^^^^^^^ +Close a page + +The ``spec`` fields of ``close_page`` commands: + +* page_id: str, page id to be closed + + Event ------------ @@ -444,4 +462,10 @@ js_yield ^^^^^^^^^^^^^^^ submit data from js. It's a common event to submit data to backend. -The ``data`` of the event is the data need to submit \ No newline at end of file +The ``data`` of the event is the data need to submit + +page_close +^^^^^^^^^^^^^^^ +Triggered when the user close the page + +The ``data`` of the event is the page id that is closed \ No newline at end of file diff --git a/pywebio/exceptions.py b/pywebio/exceptions.py index 59e02c65..692e3282 100644 --- a/pywebio/exceptions.py +++ b/pywebio/exceptions.py @@ -10,6 +10,10 @@ class SessionException(Exception): """Base class for PyWebIO session related exceptions""" +class PageClosedException(Exception): + """The page has been closed abnormally""" + + class SessionClosedException(SessionException): """The session has been closed abnormally""" diff --git a/pywebio/html/css/app.css b/pywebio/html/css/app.css index 275059af..2ccc8896 100644 --- a/pywebio/html/css/app.css +++ b/pywebio/html/css/app.css @@ -361,4 +361,53 @@ details[open]>summary { color: #6c757d; line-height: 14px; vertical-align: text-top; -} \ No newline at end of file +} + +html.overflow-y-hidden { + overflow-y: hidden !important; +} + +.pywebio-page-close-btn { + position: absolute; + right: 0; + top: 0; + z-index: 50; +} + +.btn-close { + box-sizing: content-box; + width: 1em; + height: 1em; + padding: 0.25em; + margin: 0.75em; + color: #000; + background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat; + border: 0; + border-radius: 0.25rem; + opacity: .5; + transition: opacity 0.2s ease-in-out; +} + +.btn-close:hover { + color: #000; + text-decoration: none; + opacity: .95; +} + +.pywebio-page { + width: 100%; + min-height: 100vh; /* `100%` will cause issue on mobile platform */ + position: absolute; + top: 0; + bottom: 0; + box-shadow: 4px -4px 4px rgb(0 0 0 / 20%); + margin-left: -102%; + z-index: 200; + transition: margin-left 0.4s ease-in-out; +} +@media (max-width: 425px) {.pywebio-page{transition: margin-left 0.3s ease-in-out;}} + +.pywebio-page.active { + margin-left: 0%; + background-color: white; +} diff --git a/pywebio/io_ctrl.py b/pywebio/io_ctrl.py index 4245720e..e4f2b568 100644 --- a/pywebio/io_ctrl.py +++ b/pywebio/io_ctrl.py @@ -9,6 +9,7 @@ from .session import chose_impl, next_client_event, get_current_task_id, get_current_session from .utils import random_str +from .exceptions import PageClosedException logger = logging.getLogger(__name__) @@ -63,12 +64,11 @@ def safely_destruct(cls, obj): pass def __init__(self, spec, on_embed=None): - self.processed = False + self.processed = True # avoid `__del__` is invoked accidentally when exception occurs in `__init__` self.on_embed = on_embed or (lambda d: d) try: self.spec = type(self).dump_dict(spec) # this may raise TypeError except TypeError: - self.processed = True type(self).safely_destruct(spec) raise @@ -84,7 +84,12 @@ def __init__(self, spec, on_embed=None): # the Exception raised from there will be ignored by python interpreter, # thus we can't end some session in some cases. # See also: https://github.com/pywebio/PyWebIO/issues/243 - get_current_session() + s = get_current_session() + + # Try to make sure current page is active. + # Session.get_page_id will raise PageClosedException when the page is not activate + s.get_page_id() + self.processed = False def enable_context_manager(self, container_selector=None, container_dom_id=None, custom_enter=None, custom_exit=None): @@ -214,7 +219,11 @@ def inner(*args, **kwargs): def send_msg(cmd, spec=None, task_id=None): msg = dict(command=cmd, spec=spec, task_id=task_id or get_current_task_id()) - get_current_session().send_task_command(msg) + s = get_current_session() + page = s.get_page_id() + if page is not None: + msg['page'] = page + s.send_task_command(msg) def single_input_kwargs(single_input_return): diff --git a/pywebio/output.py b/pywebio/output.py index c41c8cc5..069dc174 100644 --- a/pywebio/output.py +++ b/pywebio/output.py @@ -72,6 +72,8 @@ | | `popup`:sup:`*†` | Show popup | | +---------------------------+------------------------------------------------------------+ | | `close_popup` | Close the current popup window. | +| +---------------------------+------------------------------------------------------------+ +| | `page` | Open a new page. | +--------------------+---------------------------+------------------------------------------------------------+ | Layout and Style | `put_row`:sup:`*†` | Use row layout to output content | | +---------------------------+------------------------------------------------------------+ @@ -218,6 +220,7 @@ from .io_ctrl import output_register_callback, send_msg, Output, safely_destruct_output_when_exp, OutputList, scope2dom from .session import get_current_session, download from .utils import random_str, iscoroutinefunction, check_dom_name_value +from .exceptions import PageClosedException try: from PIL.Image import Image as PILImage @@ -231,7 +234,7 @@ 'put_table', 'put_buttons', 'put_image', 'put_file', 'PopupSize', 'popup', 'put_button', 'close_popup', 'put_widget', 'put_collapse', 'put_link', 'put_scrollable', 'style', 'put_column', 'put_row', 'put_grid', 'span', 'put_processbar', 'set_processbar', 'put_loading', - 'output', 'toast', 'get_scope', 'put_info', 'put_error', 'put_warning', 'put_success'] + 'output', 'toast', 'get_scope', 'put_info', 'put_error', 'put_warning', 'put_success', 'page'] # popup size @@ -1809,3 +1812,79 @@ async def coro_wrapper(*args, **kwargs): return coro_wrapper else: return wrapper + + +def page(new_window=False, silent_quit=False): + """ + Open a page. Can be used as context manager and decorator. + + :param bool silent_quit: whether to quit silently when the page is closed accidentally by app user + + :Usage: + + :: + + with page() as scope_name: + input() + put_xxx() + + @page() + def content(): + input() + put_xxx() + """ + p = page_(silent_quit=silent_quit, new_window=new_window) + return p + + +class page_: + page_id: str + new_window: bool + silent_quit: bool = False + + def __init__(self, silent_quit, new_window): + self.silent_quit = silent_quit + self.new_window = new_window + self.page_id = random_str(10) + + def new_page(self): + return page_(self.silent_quit, new_window=self.new_window) + + def __enter__(self): + send_msg('open_page', dict(page_id=self.page_id, new_window=self.new_window)) + get_current_session().push_page(self.page_id) + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + If this method returns True, it means that the context manager can handle the exception, + so that the with statement terminates the propagation of the exception + """ + get_current_session().pop_page() + if isinstance(exc_val, PageClosedException): # page is close by app user + if self.silent_quit: + # suppress PageClosedException Exception + return True + else: + send_msg('close_page', dict(page_id=self.page_id)) + + return False # propagate Exception + + def __call__(self, func): + """decorator implement""" + + @wraps(func) + def wrapper(*args, **kwargs): + # can't use `with self:`, it will use same object in + # different calls to same decorated func + with self.new_page(): + return func(*args, **kwargs) + + @wraps(func) + async def coro_wrapper(*args, **kwargs): + with self.new_page(): + return await func(*args, **kwargs) + + if iscoroutinefunction(func): + return coro_wrapper + else: + return wrapper diff --git a/pywebio/pin.py b/pywebio/pin.py index f66ee1ae..98f05fb2 100644 --- a/pywebio/pin.py +++ b/pywebio/pin.py @@ -130,8 +130,9 @@ from pywebio.output import OutputPosition, Output from pywebio.output import _get_output_spec from .io_ctrl import send_msg, single_input_kwargs, output_register_callback -from .session import next_client_event, chose_impl +from .session import next_client_event, chose_impl, get_current_session from .utils import check_dom_name_value +from .exceptions import PageClosedException _pin_name_chars = set(string.ascii_letters + string.digits + '_-') @@ -227,6 +228,7 @@ def put_actions(name, *, label='', buttons=None, help_text=None, @chose_impl def get_client_val(): res = yield next_client_event() + assert res['event'] == 'js_yield', "Internal Error, please report this bug on " \ "https://github.com/wang0618/PyWebIO/issues" return res['data'] diff --git a/pywebio/session/__init__.py b/pywebio/session/__init__.py index c0c05beb..7fe60297 100644 --- a/pywebio/session/__init__.py +++ b/pywebio/session/__init__.py @@ -168,7 +168,7 @@ def show(): from .base import Session from .coroutinebased import CoroutineBasedSession from .threadbased import ThreadBasedSession, ScriptModeSession -from ..exceptions import SessionNotFoundException, SessionException +from ..exceptions import SessionNotFoundException, SessionException, PageClosedException from ..utils import iscoroutinefunction, isgeneratorfunction, run_as_function, to_coroutine, ObjectDictProxy, \ ReadOnlyObjectDict @@ -287,13 +287,15 @@ def inner(*args, **kwargs): @chose_impl def next_client_event(): - res = yield get_current_session().next_client_event() - return res + session_ = get_current_session() + event = yield session_.next_client_event() + Session.client_event_pre_check(session_, event) + return event @chose_impl def hold(): - """Keep the session alive until the browser page is closed by user. + """Hold and wait the browser page is closed by user. .. attention:: @@ -315,6 +317,8 @@ def hold(): yield next_client_event() except SessionException: return + except PageClosedException: + return def download(name, content): diff --git a/pywebio/session/base.py b/pywebio/session/base.py index 2dd33a06..768dff3f 100644 --- a/pywebio/session/base.py +++ b/pywebio/session/base.py @@ -6,9 +6,12 @@ import user_agents from ..utils import catch_exp_call +from ..exceptions import PageClosedException logger = logging.getLogger(__name__) +ROOT_SCOPE = 'ROOT' + class Session: """ @@ -61,7 +64,9 @@ def __init__(self, session_info): """ self.internal_save = dict(info=session_info) # some session related info, just for internal used self.save = {} # underlying implement of `pywebio.session.data` - self.scope_stack = defaultdict(lambda: ['ROOT']) # task_id -> scope栈 + self.scope_stack = defaultdict(lambda: [ROOT_SCOPE]) # task_id -> scope栈 + self.page_stack = defaultdict(lambda: []) # task_id -> page id stack + self.active_page = defaultdict(set) # task_id -> activate page set self.deferred_functions = [] # 会话结束时运行的函数 self._closed = False @@ -94,6 +99,54 @@ def push_scope(self, name): task_id = type(self).get_current_task_id() self.scope_stack[task_id].append(name) + def get_page_id(self, check_active=True): + """ + get the if of current page in task, return `None` when it's master page, + raise PageClosedException when current page is closed + """ + task_id = type(self).get_current_task_id() + if task_id not in self.page_stack or not self.page_stack[task_id]: + # current in master page + return None + + page_id = self.page_stack[task_id][-1] + if page_id not in self.active_page[task_id] and check_active: + raise PageClosedException( + "The page is closed by app user, " + "set `silent_quit=True` in `pywebio.output.page()` to suppress this error" + ) + + return page_id + + def pop_page(self): + """exit the current page in task""" + self.pop_scope() + task_id = type(self).get_current_task_id() + try: + page_id = self.page_stack[task_id].pop() + except IndexError: + raise ValueError("Internal Error: No page to exit") from None + + try: + self.active_page[task_id].remove(page_id) + except KeyError: + pass + return page_id + + def push_page(self, page_id, task_id=None): + self.push_scope(ROOT_SCOPE) + if task_id is None: + task_id = type(self).get_current_task_id() + self.page_stack[task_id].append(page_id) + self.active_page[task_id].add(page_id) + + def notify_page_lost(self, task_id, page_id): + """update page status when there is page lost""" + try: + self.active_page[task_id].remove(page_id) + except KeyError: + pass + def send_task_command(self, command): raise NotImplementedError @@ -101,7 +154,17 @@ def next_client_event(self) -> dict: """获取来自客户端的下一个事件。阻塞调用,若在等待过程中,会话被用户关闭,则抛出SessionClosedException异常""" raise NotImplementedError + @staticmethod + def client_event_pre_check(session: "Session", event): + """This method is called before dispatch client event""" + if event['event'] == 'page_close': + current_page = session.get_page_id(check_active=False) + closed_page = event['data'] + if closed_page == current_page: + raise PageClosedException + def send_client_event(self, event): + """send event from client to session""" raise NotImplementedError def get_task_commands(self) -> list: @@ -164,6 +227,10 @@ def defer_call(self, func): self.deferred_functions.append(func) def need_keep_alive(self) -> bool: + """ + return whether to need to hold this session if it runs over now. + if the session maintains some event callbacks, it needs to hold session unit user close the session + """ raise NotImplementedError diff --git a/pywebio/session/coroutinebased.py b/pywebio/session/coroutinebased.py index 750f2c10..0865227e 100644 --- a/pywebio/session/coroutinebased.py +++ b/pywebio/session/coroutinebased.py @@ -138,6 +138,9 @@ def send_client_event(self, event): :param dict event: 事件️消息 """ + if event['event'] == 'page_close': + self.notify_page_lost(event['task_id'], event['data']) + coro_id = event['task_id'] coro = self.coros.get(coro_id) if not coro: @@ -173,6 +176,7 @@ def register_callback(self, callback, mutex_mode=False): :param bool mutex_mode: 互斥模式。若为 ``True`` ,则在运行回调函数过程中,无法响应同一组件(callback_id相同)的新点击事件,仅当 ``callback`` 为协程函数时有效 :return str: 回调id. """ + page_id = self.get_page_id() async def callback_coro(): while True: @@ -201,7 +205,7 @@ async def callback_coro(): if mutex_mode: await coro else: - self.run_async(coro) + self._run_async(coro, page_id=page_id) cls = type(self) callback_task = Task(callback_coro(), cls.get_current_session()) @@ -221,12 +225,18 @@ def run_async(self, coro_obj): :param coro_obj: 协程对象 :return: An instance of `TaskHandler` is returned, which can be used later to close the task. """ + return self._run_async(coro_obj) + + def _run_async(self, coro_obj, page_id=None): assert asyncio.iscoroutine(coro_obj), '`run_async()` only accept coroutine object' + if page_id is None: + page_id = self.get_page_id() self._alive_coro_cnt += 1 - task = Task(coro_obj, session=self, on_coro_stop=self._on_task_finish) self.coros[task.coro_id] = task + if page_id is not None: + self.push_page(page_id, task_id=task.coro_id) asyncio.get_event_loop().call_soon_threadsafe(task.step) return task.task_handle() diff --git a/pywebio/session/threadbased.py b/pywebio/session/threadbased.py index cac77d11..fd5997a3 100644 --- a/pywebio/session/threadbased.py +++ b/pywebio/session/threadbased.py @@ -146,6 +146,9 @@ def send_client_event(self, event): :param dict event: 事件️消息 """ + if event['event'] == 'page_close': + self.notify_page_lost(event['task_id'], event['data']) + task_id = event['task_id'] mq = self.task_mqs.get(task_id) if not mq and task_id in self.callbacks: @@ -249,7 +252,7 @@ def _dispatch_callback_event(self): if not callback_info: logger.error("No callback for callback_id:%s", event['task_id']) return - callback, mutex = callback_info + callback, mutex, page_id = callback_info @wraps(callback) def run(callback): @@ -267,7 +270,7 @@ def run(callback): else: t = threading.Thread(target=run, kwargs=dict(callback=callback), daemon=True) - self.register_thread(t) + self._register_thread(t, page_id) t.start() def register_callback(self, callback, serial_mode=False): @@ -282,7 +285,7 @@ def register_callback(self, callback, serial_mode=False): self._activate_callback_env() callback_id = 'CB-%s-%s' % (get_function_name(callback, 'callback'), random_str(10)) - self.callbacks[callback_id] = (callback, serial_mode) + self.callbacks[callback_id] = (callback, serial_mode, self.get_page_id()) return callback_id def register_thread(self, t: threading.Thread): @@ -291,10 +294,17 @@ def register_thread(self, t: threading.Thread): :param threading.Thread thread: 线程对象 """ + return self._register_thread(t) + + def _register_thread(self, t: threading.Thread, page_id=None): self.threads.append(t) # 保存 registered thread,用于主任务线程退出后等待注册线程结束 self.thread2session[id(t)] = self # 用于在线程内获取会话 event_mq = queue.Queue(maxsize=self.event_mq_maxsize) # 线程内的用户事件队列 self.task_mqs[self._get_task_id(t)] = event_mq + if page_id is None: + page_id = self.get_page_id() + if page_id is not None: + self.push_page(page_id, task_id=self._get_task_id(t)) def need_keep_alive(self) -> bool: # if callback thread is activated, then the session need to keep alive diff --git a/webiojs/src/handlers/base.ts b/webiojs/src/handlers/base.ts index 70c4c6f8..f09a74fb 100644 --- a/webiojs/src/handlers/base.ts +++ b/webiojs/src/handlers/base.ts @@ -1,4 +1,6 @@ import {Command, Session} from "../session"; +import {DeliverMessage} from "../models/page"; +import {PAGE_COMMANDS} from "./page"; export interface CommandHandler { @@ -35,10 +37,13 @@ export class CommandDispatcher { } dispatch_message(msg: Command): boolean { - if (msg.command in this.command2handler) { + if (msg.page !== undefined && msg.page && PAGE_COMMANDS.indexOf(msg.command) == -1) { + DeliverMessage(msg); + } else if (msg.command in this.command2handler) { this.command2handler[msg.command].handle_message(msg); - return true; + } else { + return false } - return false + return true; } } \ No newline at end of file diff --git a/webiojs/src/handlers/page.ts b/webiojs/src/handlers/page.ts new file mode 100644 index 00000000..1e5820b0 --- /dev/null +++ b/webiojs/src/handlers/page.ts @@ -0,0 +1,20 @@ +import {Command} from "../session"; +import {CommandHandler} from "./base"; +import {ClosePage, OpenPage} from "../models/page"; + +export const PAGE_COMMANDS = ['open_page', 'close_page'] + +export class PageHandler implements CommandHandler { + accept_command: string[] = PAGE_COMMANDS; + + constructor() { + } + + handle_message(msg: Command) { + if (msg.command === 'open_page') { + OpenPage(msg.spec.page_id, msg.task_id, msg.page, msg.spec.new_window); + } else if (msg.command === 'close_page') { + ClosePage(msg.spec.page_id); + } + } +} \ No newline at end of file diff --git a/webiojs/src/i18n.ts b/webiojs/src/i18n.ts index e31e56ac..db097acc 100644 --- a/webiojs/src/i18n.ts +++ b/webiojs/src/i18n.ts @@ -18,6 +18,7 @@ const translations: { [lang: string]: { [msgid: string]: string } } = { "duplicated_pin_name": "This pin widget has expired (due to the output of a new pin widget with the same name).", "browse_file": "Browse", "duplicated_scope_name": "Error: The name of this scope is duplicated with the previous one!", + "page_blocked": "Failed to open new page: blocked by browser", }, "zh": { "disconnected_with_server": "与服务器连接已断开,请刷新页面重新操作", @@ -31,6 +32,7 @@ const translations: { [lang: string]: { [msgid: string]: string } } = { "duplicated_pin_name": "该 Pin widget 已失效(由于输出了新的同名 pin widget)", "browse_file": "浏览文件", "duplicated_scope_name": "错误: 此scope与已有scope重复!", + "page_blocked": "无法打开新页面(页面被浏览器拦截)", }, "ru": { "disconnected_with_server": "Соединение с сервером потеряно, пожалуйста перезагрузите страницу", @@ -81,7 +83,7 @@ function strfmt(fmt: string) { let args = arguments; return fmt - // put space after double % to prevent placeholder replacement of such matches + // put space after double % to prevent placeholder replacement of such matches .replace(/%%/g, '%% ') // replace placeholders .replace(/%(\d+)/g, function (str, p1) { @@ -91,10 +93,10 @@ function strfmt(fmt: string) { .replace(/%% /g, '%') } -export function t(msgid: string, ...args:string[]): string { +export function t(msgid: string, ...args: string[]): string { let fmt = null; for (let lang of ['custom', userLangCode, langPrefix, 'en']) { - if (translations[lang] && translations[lang][msgid]){ + if (translations[lang] && translations[lang][msgid]) { fmt = translations[lang][msgid]; break; } diff --git a/webiojs/src/main.ts b/webiojs/src/main.ts index 52825fbb..5acbf8ee 100644 --- a/webiojs/src/main.ts +++ b/webiojs/src/main.ts @@ -1,5 +1,5 @@ import {config as appConfig, state} from "./state"; -import {Command, HttpSession, is_http_backend, Session, WebSocketSession, pushData} from "./session"; +import {Command, HttpSession, detect_backend, Session, WebSocketSession, pushData, SubPageSession} from "./session"; import {InputHandler} from "./handlers/input" import {OutputHandler} from "./handlers/output" import {CommandDispatcher, SessionCtrlHandler} from "./handlers/base" @@ -11,6 +11,7 @@ import {ToastHandler} from "./handlers/toast"; import {EnvSettingHandler} from "./handlers/env"; import {PinHandler} from "./handlers/pin"; import {customMessage} from "./i18n" +import {PageHandler} from "./handlers/page"; // 获取后端API的绝对地址 function backend_absaddr(addr: string) { @@ -40,9 +41,10 @@ function set_up_session(webio_session: Session, output_container_elem: JQuery, i let download_ctrl = new DownloadHandler(); let toast_ctrl = new ToastHandler(); let env_ctrl = new EnvSettingHandler(); + let page_ctrl = new PageHandler(); let dispatcher = new CommandDispatcher(output_ctrl, input_ctrl, popup_ctrl, session_ctrl, - script_ctrl, download_ctrl, toast_ctrl, env_ctrl, pin_ctrl); + script_ctrl, download_ctrl, toast_ctrl, env_ctrl, pin_ctrl, page_ctrl); webio_session.on_server_message((msg: Command) => { try { @@ -69,19 +71,27 @@ function startWebIOClient(options: { } const backend_addr = backend_absaddr(options.backend_address); - let start_session = (is_http: boolean) => { + let start_session = (session_type: String) => { let session; - if (is_http) + if (session_type === 'http') session = new HttpSession(backend_addr, options.app_name, appConfig.httpPullInterval); - else + else if (session_type === 'ws') session = new WebSocketSession(backend_addr, options.app_name); + else if (session_type === 'page') + session = new SubPageSession() + else + throw `Unsupported session type: ${session_type}`; + set_up_session(session, options.output_container_elem, options.input_container_elem); session.start_session(appConfig.debug); }; - if (options.protocol == 'auto') - is_http_backend(backend_addr).then(start_session); + + if (SubPageSession.is_sub_page()) { + start_session('page') + } else if (options.protocol == 'auto') + detect_backend(backend_addr).then(start_session); else - start_session(options.protocol == 'http') + start_session(options.protocol) } diff --git a/webiojs/src/models/page.ts b/webiojs/src/models/page.ts new file mode 100644 index 00000000..30666f29 --- /dev/null +++ b/webiojs/src/models/page.ts @@ -0,0 +1,251 @@ +// page id to page window reference +import {Command, SubPageSession} from "../session"; +import {error_alert, LazyPromise} from "../utils"; +import {state} from "../state"; +import {t} from "../i18n"; + +let subpages: { + [page_id: string]: SubPage +} = {}; + +function start_clean_up_task() { + return setInterval(() => { + for (let page_id in subpages) { + subpages[page_id].page.promise.then((page: Window) => { + if (page.closed || !SubPageSession.is_sub_page(page)) { + on_page_lost(page_id); + } + }) + } + }, 1000) +} + +export declare type PageArgs = { + page_id: String, + page_session: LazyPromise, + master_window: Window, + on_terminate: () => void, +}; + +// export declare let _pywebio_page_args: PageArgs; + +class SubPage { + page: LazyPromise + task_id: string + session: LazyPromise + + private iframe: HTMLIFrameElement = null; + private top: SubPage = null; + private parent: SubPage; + + private page_id: string; + private new_window: boolean; + private page_tasks: ((w: Window) => void)[]; + + /* + * new_window: whether to open sub-page as new browser window or iframe + * */ + constructor(args: { page_id: string, task_id: string, parent: SubPage, new_window: boolean }) { + // will be resolved as new opened page + this.page = new LazyPromise(); + // will be resolved as SubPageSession in new opened page in `SubPageSession.start_session()` + this.session = new LazyPromise(); + this.task_id = args.task_id; + + this.page_id = args.page_id; + this.parent = args.parent; + this.new_window = args.new_window; + this.page_tasks = []; + } + + start() { + if (this.new_window) { // open sub-page in new browser window + if (this.parent == null) { + this.init_page(window.open(window.location.href)); + } else { + // open the new page in currently active window, + // otherwise, the opening action may be blocked by browser. + this.parent.run_in_page_context((context: Window) => { + this.init_page(context.open(window.location.href)); + }); + } + } else { // open sub-page as iframe + let context: SubPage = this.parent; + while (context != null && !context.new_window) + context = context.parent; + this.top = context; + + if (context == null) { + this.iframe = SubPage.build_iframe(window); + this.init_page(this.iframe.contentWindow); + } else { + context.page.promise.then((w: Window) => { + this.iframe = SubPage.build_iframe(w); + this.init_page(this.iframe.contentWindow); + }); + } + } + } + + static build_iframe(context: Window) { + let iframe = context.document.createElement("iframe"); + iframe.classList.add('pywebio-page'); + iframe.src = location.href; + iframe.frameBorder = "0"; + + // add iframe to DOM + context.document.getElementsByTagName('body')[0].appendChild(iframe); + + // must after the iframe is added to DOM + context.setTimeout(() => { + // show iframe + iframe.classList.add('active'); + // disable the scrollbar in body + context.document.documentElement.classList.add('overflow-y-hidden'); + }, 10); + + return iframe; + } + + remove_iframe() { + this.iframe.classList.remove('active'); + setTimeout(() => { + this.iframe.remove(); + }, 1000); + + if (this.top == null) { + if ($('body > .pywebio-page.active').length == 0) + document.documentElement.classList.remove('overflow-y-hidden'); + } else { + this.top.page.promise.then((w: any) => { + if (w.$('body > .pywebio-page.active').length == 0) + w.document.documentElement.classList.remove('overflow-y-hidden'); + }); + } + } + + /* + * set up the page + * */ + private init_page(page: Window) { + if (page == null) { // page is blocked by browser; only can occur when open in new window + on_page_lost(this.page_id); + return error_alert(t("page_blocked")); + } + + let args: PageArgs = { + page_id: this.page_id, + page_session: this.session, + master_window: window, + on_terminate: () => { + this.remove_iframe(); + on_page_lost(this.page_id); + } + } + // @ts-ignore + page._pywebio_page_args = args; + + page.addEventListener('message', event => { + while (this.page_tasks.length) { + this.page_tasks.shift()(page); // pop first + } + }); + + // For page opened in new window + // this event is not reliably fired by browsers + // https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event#usage_notes + page.addEventListener('pagehide', event => { + // wait some time to for `page.closed` + setTimeout(() => { + if (page.closed || !SubPageSession.is_sub_page(page)) + on_page_lost(this.page_id) + }, 100) + }); + + this.page.resolve(page); + } + + run_in_page_context(func: (w: Window) => void) { + this.page_tasks.push(func); + this.page.promise.then((w: Window) => { + // when the page window receive this message, + // it will run the tasks in `page_tasks` + w.postMessage("", "*"); + }); + } + + close() { + if (this.new_window) { + this.page.promise.then((page: Window) => page.close()); + } else { + this.remove_iframe(); + } + } +} + + +// page is closed accidentally +function on_page_lost(page_id: string) { + console.debug(`page ${page_id} exit`); + if (!(page_id in subpages)) // it's a duplicated call + return; + + let task_id = subpages[page_id].task_id; + delete subpages[page_id]; + state.CurrentSession.send_message({ + event: "page_close", + task_id: task_id, + data: page_id + }); +} + +let clean_up_task_id: number = null; + +export function OpenPage(page_id: string, task_id: string, parent_page: string, new_window: boolean) { + if (page_id in subpages) + throw `Can't open page, the page id "${page_id}" is duplicated`; + + if (!clean_up_task_id) + clean_up_task_id = start_clean_up_task(); + + let parent: SubPage = null; + if (parent_page) + parent = subpages[parent_page]; + + let page = new SubPage({ + page_id: page_id, + task_id: task_id, + parent: parent, + new_window: new_window, + }); + subpages[page_id] = page; + page.start() +} + + +// close page by server +export function ClosePage(page_id: string) { + if (!(page_id in subpages)) { + throw `Can't close page, the page (id "${page_id}") is not found`; + } + subpages[page_id].close(); + delete subpages[page_id]; +} + +export function DeliverMessage(msg: Command) { + if (!(msg.page in subpages)) + throw `Can't deliver message, the page (id "${msg.page}") is not found`; + subpages[msg.page].session.promise.then((page: SubPageSession) => { + msg.page = undefined; + page.server_message(msg); + }); +} + +// close all subpage's session +export function CloseSession() { + for (let page_id in subpages) { + subpages[page_id].session.promise.then((page: SubPageSession) => { + page.close_session() + }); + } +} \ No newline at end of file diff --git a/webiojs/src/session.ts b/webiojs/src/session.ts index a0b5cd22..903aabde 100644 --- a/webiojs/src/session.ts +++ b/webiojs/src/session.ts @@ -1,10 +1,13 @@ import {error_alert} from "./utils"; import {state} from "./state"; import {t} from "./i18n"; +import {CloseSession} from "./models/page"; +import {PageArgs} from "./models/page"; export interface Command { command: string task_id: string + page: string, spec: any } @@ -23,16 +26,21 @@ export interface ClientEvent { export interface Session { webio_session_id: string; + // add session creation callback on_session_create(callback: () => void): void; + // add session close callback on_session_close(callback: () => void): void; + // add session message received callback on_server_message(callback: (msg: Command) => void): void; start_session(debug: boolean): void; + // send text message to server send_message(msg: ClientEvent, onprogress?: (loaded: number, total: number) => void): void; + // send binary message to server send_buffer(data: Blob, onprogress?: (loaded: number, total: number) => void): void; close_session(): void; @@ -49,11 +57,119 @@ function safe_poprun_callbacks(callbacks: (() => void)[], name = 'callback') { } } +export class SubPageSession implements Session { + webio_session_id: string = ''; + debug: boolean; + private _master_id: string; + private _master_window: any; + private _closed: boolean = false; + + private _session_create_callbacks: (() => void)[] = []; + private _session_close_callbacks: (() => void)[] = []; + private _on_server_message: (msg: Command) => any = () => { + }; + + // check if the window is a pywebio subpage + static is_sub_page(window_obj: Window = window): boolean { + try { + // @ts-ignore + if (window_obj._pywebio_page_args !== undefined) { + // @ts-ignore + if (window_obj.opener !== null && window_obj.opener.WebIO !== undefined) + return true; + + // @ts-ignore + if (window_obj.parent != window_obj && window_obj.parent.WebIO !== undefined) + return true; + } + } catch (e) { + } + return false; + } + + // check if the master page is active + is_master_active(): boolean { + return this._master_window && this._master_window.WebIO && + !this._master_window.WebIO._state.CurrentSession.closed() && + this._master_id == this._master_window.WebIO._state.Random; + } + + on_session_create(callback: () => any): void { + this._session_create_callbacks.push(callback); + }; + + on_session_close(callback: () => any): void { + this._session_close_callbacks.push(callback); + } + + on_server_message(callback: (msg: Command) => any): void { + this._on_server_message = callback; + } + + start_session(debug: boolean): void { + this.debug = debug; + safe_poprun_callbacks(this._session_create_callbacks, 'session_create_callback'); + + // @ts-ignore + let page_args:PageArgs = window._pywebio_page_args; + + this._master_window = page_args.master_window; + this._master_id = this._master_window.WebIO._state.Random; + + page_args.page_session.resolve(this); + + let check_active_id = setInterval(() => { + if (!this.is_master_active()) + this.close_session(); + if (this.closed()) + clearInterval(check_active_id); + }, 300); + + if (window.parent != window) { // this window is in an iframe + // show page close button + let close_btn = $('').on('click', () => { + page_args.on_terminate() + }); + $('body').append(close_btn); + } + }; + + // called by master, transfer command to this session + server_message(command: Command) { + if (this.debug) + console.info('>>>', command); + this._on_server_message(command); + } + + // send text message to master + send_message(msg: ClientEvent, onprogress?: (loaded: number, total: number) => void): void { + if (this.closed() || !this.is_master_active()) + return error_alert(t("disconnected_with_server")); + this._master_window.WebIO._state.CurrentSession.send_message(msg, onprogress); + } + + // send binary message to master + send_buffer(data: Blob, onprogress?: (loaded: number, total: number) => void): void { + if (this.closed() || !this.is_master_active()) + return error_alert(t("disconnected_with_server")); + this._master_window.WebIO._state.CurrentSession.send_buffer(data, onprogress); + } + + close_session(): void { + this._closed = true; + safe_poprun_callbacks(this._session_close_callbacks, 'session_close_callback'); + } + + closed(): boolean { + return this._closed; + } +} + export class WebSocketSession implements Session { ws: WebSocket; debug: boolean; webio_session_id: string = 'NEW'; - private _closed: boolean; // session logic closed (by `close_session` command) + private _closed: boolean; // session logical closed (by `close_session` command) private _session_create_ts = 0; private _session_create_callbacks: (() => void)[] = []; private _session_close_callbacks: (() => void)[] = []; @@ -164,6 +280,7 @@ export class WebSocketSession implements Session { close_session(): void { this._closed = true; safe_poprun_callbacks(this._session_close_callbacks, 'session_close_callback'); + CloseSession() try { this.ws.close(); } catch (e) { @@ -291,6 +408,7 @@ export class HttpSession implements Session { close_session(): void { this._closed = true; safe_poprun_callbacks(this._session_close_callbacks, 'session_close_callback'); + CloseSession() clearInterval(this.interval_pull_id); } @@ -308,12 +426,11 @@ export class HttpSession implements Session { } /* -* Check given `backend_addr` is a http backend +* Check backend type: http or ws * Usage: -* // `http_backend` is a boolean to present whether or not a http_backend the given `backend_addr` is -* is_http_backend('http://localhost:8080/io').then(function(http_backend){ }); +* detect_backend('http://localhost:8080/io').then(function(backend_type){ }); * */ -export function is_http_backend(backend_addr: string) { +export function detect_backend(backend_addr: string) { let url = new URL(backend_addr); let protocol = url.protocol || window.location.protocol; url.protocol = protocol.replace('wss', 'https').replace('ws', 'http'); @@ -321,9 +438,9 @@ export function is_http_backend(backend_addr: string) { return new Promise(function (resolve, reject) { $.get(backend_addr, {test: 1}, undefined, 'html').done(function (data: string) { - resolve(data === 'ok'); + resolve(data === 'ok' ? 'http' : 'ws'); }).fail(function (e: JQuery.jqXHR) { - resolve(false); + resolve('ws'); }); }); } diff --git a/webiojs/src/state.ts b/webiojs/src/state.ts index 2ceffc2d..d819d669 100644 --- a/webiojs/src/state.ts +++ b/webiojs/src/state.ts @@ -1,4 +1,5 @@ import {Session} from "./session"; +import {randomid} from "./utils"; // Runtime state export let state = { @@ -9,6 +10,7 @@ export let state = { InputPanelInitHeight: 300, // 输入panel的初始高度 FixedInputPanel:true, AutoFocusOnInput:true, + Random: randomid(10), }; // App config diff --git a/webiojs/src/utils.ts b/webiojs/src/utils.ts index c5a01f88..125bf1db 100644 --- a/webiojs/src/utils.ts +++ b/webiojs/src/utils.ts @@ -176,4 +176,26 @@ function int2bytes(num: number) { dataView.setUint32(0, (num / 4294967296) | 0); // 4294967296 == 2^32 dataView.setUint32(4, num | 0); return buf; +} + + +export class LazyPromise { + /* + * Execute operations when some the dependency is ready. + * + * Add pending operations: + * LazyPromise.promise.then((dependency)=> ...) + * Mark dependency is ready: + * LazyPromise.promise.resolve(dependency) + * */ + public promise: Promise; + public resolve: (_: Type) => void; + public reject: (_: Type) => void; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } } \ No newline at end of file