From 6b4bd0c836e6f7d85c93d625bae469b1679a9915 Mon Sep 17 00:00:00 2001 From: wangweimin Date: Sat, 25 Jun 2022 19:22:17 +0800 Subject: [PATCH 01/17] add `SubPageSession` --- webiojs/src/main.ts | 22 +++++++---- webiojs/src/session.ts | 83 ++++++++++++++++++++++++++++++++++++++---- webiojs/src/utils.ts | 22 +++++++++++ 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/webiojs/src/main.ts b/webiojs/src/main.ts index 52825fbb..443a1b3c 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" @@ -69,19 +69,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/session.ts b/webiojs/src/session.ts index a0b5cd22..e6aca696 100644 --- a/webiojs/src/session.ts +++ b/webiojs/src/session.ts @@ -23,16 +23,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 +54,76 @@ function safe_poprun_callbacks(callbacks: (() => void)[], name = 'callback') { } } +export class SubPageSession implements Session { + webio_session_id: string = ''; + debug: boolean; + private _closed: boolean = false; + + private _session_create_callbacks: (() => void)[] = []; + private _session_close_callbacks: (() => void)[] = []; + private _on_server_message: (msg: Command) => any = () => { + }; + + // check if it's a pywebio subpage + static is_sub_page(): boolean { + // - `window._pywebio_page` lazy promise is not undefined + // - window.opener is not null and window.opener.WebIO is not undefined + // @ts-ignore + return window._pywebio_page !== undefined && window.opener !== null && window.opener.WebIO !== undefined; + } + + 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 + window._pywebio_page.promise.resolve(this); + }; + + // called by opener, transfer command to this session + server_message(command: Command) { + if (this.debug) + console.info('>>>', command); + this._on_server_message(command); + } + + // send text message to opener + send_message(msg: ClientEvent, onprogress?: (loaded: number, total: number) => void): void { + window.opener.WebIO._state.CurrentSession.send_message(msg, onprogress); + } + + // send binary message to opener + send_buffer(data: Blob, onprogress?: (loaded: number, total: number) => void): void { + window.opener.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)[] = []; @@ -308,12 +378,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 +390,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/utils.ts b/webiojs/src/utils.ts index c5a01f88..febd03a5 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: (_: any) => void; + public reject: (_: any) => void; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } } \ No newline at end of file From 95f07aa1ae6353c9ee5efbfc1302f16bd3bd19f2 Mon Sep 17 00:00:00 2001 From: wangweimin Date: Sat, 25 Jun 2022 21:47:46 +0800 Subject: [PATCH 02/17] add `output.page()` --- docs/spec.rst | 17 +++++++++ pywebio/io_ctrl.py | 6 ++- pywebio/output.py | 71 +++++++++++++++++++++++++++++++++++- pywebio/session/base.py | 21 +++++++++++ webiojs/src/handlers/base.ts | 10 +++-- webiojs/src/handlers/page.ts | 18 +++++++++ webiojs/src/main.ts | 4 +- webiojs/src/models/page.ts | 32 ++++++++++++++++ webiojs/src/session.ts | 3 +- 9 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 webiojs/src/handlers/page.ts create mode 100644 webiojs/src/models/page.ts diff --git a/docs/spec.rst b/docs/spec.rst index 3f06640b..78e1cc72 100644 --- a/docs/spec.rst +++ b/docs/spec.rst @@ -395,6 +395,23 @@ 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 + +close_page +^^^^^^^^^^^^^^^ +Close a page + +The ``spec`` fields of ``close_page`` commands: + +* page_id: str, page id to be closed + + Event ------------ diff --git a/pywebio/io_ctrl.py b/pywebio/io_ctrl.py index 4245720e..43bc1c01 100644 --- a/pywebio/io_ctrl.py +++ b/pywebio/io_ctrl.py @@ -214,7 +214,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..10dd188d 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 | | +---------------------------+------------------------------------------------------------+ @@ -231,7 +233,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 +1811,70 @@ async def coro_wrapper(*args, **kwargs): return coro_wrapper else: return wrapper + + +def page(func=None): + """ + Open a page. Can be used as context manager and decorator. + + :Usage: + + :: + + with page() as scope_name: + input() + put_xxx() + + @page() # or @page + def content(): + input() + put_xxx() + """ + + if func is None: + return page_() + return page_()(func) + + +class page_: + page_id: str + + def __enter__(self): + self.page_id = random_str(10) + send_msg('open_page', dict(page_id=self.page_id)) + 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() + send_msg('close_page', dict(page_id=self.page_id)) + + # todo: catch Page Close Exception + return False # Propagate Exception + + def __call__(self, func): + """decorator implement""" + + @wraps(func) + def wrapper(*args, **kwargs): + self.__enter__() + try: + return func(*args, **kwargs) + finally: + self.__exit__(None, None, None) + + @wraps(func) + async def coro_wrapper(*args, **kwargs): + self.__enter__() + try: + return await func(*args, **kwargs) + finally: + self.__exit__(None, None, None) + + if iscoroutinefunction(func): + return coro_wrapper + else: + return wrapper diff --git a/pywebio/session/base.py b/pywebio/session/base.py index 2dd33a06..f25dec21 100644 --- a/pywebio/session/base.py +++ b/pywebio/session/base.py @@ -62,6 +62,7 @@ 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.page_stack = defaultdict(lambda: []) # task_id -> page id self.deferred_functions = [] # 会话结束时运行的函数 self._closed = False @@ -94,6 +95,26 @@ 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): + task_id = type(self).get_current_task_id() + if task_id not in self.page_stack: + return None + try: + return self.page_stack[task_id][-1] + except IndexError: + return None + + def pop_page(self): + task_id = type(self).get_current_task_id() + try: + return self.page_stack[task_id].pop() + except IndexError: + raise ValueError("Internal Error: No page to exit") from None + + def push_page(self, page_id): + task_id = type(self).get_current_task_id() + self.page_stack[task_id].append(page_id) + def send_task_command(self, command): raise NotImplementedError diff --git a/webiojs/src/handlers/base.ts b/webiojs/src/handlers/base.ts index 70c4c6f8..af28fd67 100644 --- a/webiojs/src/handlers/base.ts +++ b/webiojs/src/handlers/base.ts @@ -1,4 +1,5 @@ import {Command, Session} from "../session"; +import {DeliverMessage} from "../models/page"; export interface CommandHandler { @@ -35,10 +36,13 @@ export class CommandDispatcher { } dispatch_message(msg: Command): boolean { - if (msg.command in this.command2handler) { + if (msg.page !== undefined && msg.page) { + 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..85e76fe0 --- /dev/null +++ b/webiojs/src/handlers/page.ts @@ -0,0 +1,18 @@ +import {Command} from "../session"; +import {CommandHandler} from "./base"; +import {ClosePage, OpenPage} from "../models/page"; + +export class PageHandler implements CommandHandler { + accept_command: string[] = ['open_page', 'close_page']; + + constructor() { + } + + handle_message(msg: Command) { + if (msg.command === 'open_page') { + OpenPage(msg.spec.page_id); + } else if (msg.command === 'close_page') { + ClosePage(msg.spec.page_id); + } + } +} \ No newline at end of file diff --git a/webiojs/src/main.ts b/webiojs/src/main.ts index 443a1b3c..5acbf8ee 100644 --- a/webiojs/src/main.ts +++ b/webiojs/src/main.ts @@ -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 { diff --git a/webiojs/src/models/page.ts b/webiojs/src/models/page.ts new file mode 100644 index 00000000..bba4ca93 --- /dev/null +++ b/webiojs/src/models/page.ts @@ -0,0 +1,32 @@ +// page id to page window reference +import {Command, SubPageSession} from "../session"; +import {LazyPromise} from "../utils"; + +let subpages: { [page_id: string]: Window } = {}; + + +export function OpenPage(page_id: string) { + if (page_id in subpages) + throw `Can't open page, the page id "${page_id}" is duplicated`; + subpages[page_id] = window.open(window.location.href); + + // the `_pywebio_page` will be resolved in new opened page in `SubPageSession.start_session()` + // @ts-ignore + subpages[page_id]._pywebio_page = new LazyPromise() +} + +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() +} + +export function DeliverMessage(msg: Command) { + if (!(msg.page in subpages)) + throw `Can't deliver message, the page (id "${msg.page}") is not found`; + // @ts-ignore + subpages[msg.page]._pywebio_page.promise.then((page: SubPageSession) => { + msg.page = undefined; + page.server_message(msg); + }); +} \ No newline at end of file diff --git a/webiojs/src/session.ts b/webiojs/src/session.ts index e6aca696..8fa9415d 100644 --- a/webiojs/src/session.ts +++ b/webiojs/src/session.ts @@ -5,6 +5,7 @@ import {t} from "./i18n"; export interface Command { command: string task_id: string + page: string, spec: any } @@ -89,7 +90,7 @@ export class SubPageSession implements Session { safe_poprun_callbacks(this._session_create_callbacks, 'session_create_callback'); // @ts-ignore - window._pywebio_page.promise.resolve(this); + window._pywebio_page.resolve(this); }; // called by opener, transfer command to this session From 037c960647e74d09c7da24cca98d12e343d1fb42 Mon Sep 17 00:00:00 2001 From: wangweimin Date: Sun, 26 Jun 2022 13:56:51 +0800 Subject: [PATCH 03/17] raise error when output after page close --- docs/spec.rst | 8 +++- pywebio/exceptions.py | 4 ++ pywebio/io_ctrl.py | 10 +++-- pywebio/output.py | 35 ++++++++--------- pywebio/session/base.py | 53 ++++++++++++++++++++++---- pywebio/session/coroutinebased.py | 4 ++ pywebio/session/threadbased.py | 4 ++ webiojs/src/handlers/page.ts | 2 +- webiojs/src/models/page.ts | 62 ++++++++++++++++++++++++++++--- webiojs/src/session.ts | 13 +++++-- 10 files changed, 155 insertions(+), 40 deletions(-) diff --git a/docs/spec.rst b/docs/spec.rst index 78e1cc72..6baaa837 100644 --- a/docs/spec.rst +++ b/docs/spec.rst @@ -461,4 +461,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/io_ctrl.py b/pywebio/io_ctrl.py index 43bc1c01..ef204702 100644 --- a/pywebio/io_ctrl.py +++ b/pywebio/io_ctrl.py @@ -63,12 +63,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 +83,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): diff --git a/pywebio/output.py b/pywebio/output.py index 10dd188d..3c810dd3 100644 --- a/pywebio/output.py +++ b/pywebio/output.py @@ -220,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 @@ -1813,10 +1814,12 @@ async def coro_wrapper(*args, **kwargs): return wrapper -def page(func=None): +def page(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: :: @@ -1825,19 +1828,19 @@ def page(func=None): input() put_xxx() - @page() # or @page + @page() def content(): input() put_xxx() """ - - if func is None: - return page_() - return page_()(func) + p = page_() + p.silent_quit = silent_quit + return p class page_: page_id: str + silent_quit: bool def __enter__(self): self.page_id = random_str(10) @@ -1850,29 +1853,27 @@ def __exit__(self, exc_type, exc_val, exc_tb): so that the with statement terminates the propagation of the exception """ get_current_session().pop_page() - send_msg('close_page', dict(page_id=self.page_id)) + if isinstance(exc_val, PageClosedException): # page is close by app user + if self.silent_quit: + # supress PageClosedException Exception + return True + else: + send_msg('close_page', dict(page_id=self.page_id)) - # todo: catch Page Close Exception - return False # Propagate Exception + return False # propagate Exception def __call__(self, func): """decorator implement""" @wraps(func) def wrapper(*args, **kwargs): - self.__enter__() - try: + with self: return func(*args, **kwargs) - finally: - self.__exit__(None, None, None) @wraps(func) async def coro_wrapper(*args, **kwargs): - self.__enter__() - try: + with self: return await func(*args, **kwargs) - finally: - self.__exit__(None, None, None) if iscoroutinefunction(func): return coro_wrapper diff --git a/pywebio/session/base.py b/pywebio/session/base.py index f25dec21..51d17aeb 100644 --- a/pywebio/session/base.py +++ b/pywebio/session/base.py @@ -6,6 +6,7 @@ import user_agents from ..utils import catch_exp_call +from ..exceptions import PageClosedException logger = logging.getLogger(__name__) @@ -62,7 +63,8 @@ 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.page_stack = defaultdict(lambda: []) # task_id -> page id + 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 @@ -96,24 +98,49 @@ def push_scope(self, name): self.scope_stack[task_id].append(name) def get_page_id(self): + """ + 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: - return None - try: - return self.page_stack[task_id][-1] - except IndexError: + 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]: + raise PageClosedException( + "The page is closed by app user, " + "you see this exception mostly because you set `silent_quit=False` in `pywebio.output.page()`" + ) + + return page_id + def pop_page(self): + """exit the current page in task""" task_id = type(self).get_current_task_id() try: - return self.page_stack[task_id].pop() + 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 = 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 @@ -123,7 +150,13 @@ def next_client_event(self) -> dict: raise NotImplementedError def send_client_event(self, event): - raise NotImplementedError + """send event from client to session, + return True when this event is handled by this method, which means the subclass must ignore this event. + """ + if event['event'] == 'page_close': + self.notify_page_lost(event['task_id'], event['data']) + return True + return False def get_task_commands(self) -> list: raise NotImplementedError @@ -185,6 +218,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..0d754ff1 100644 --- a/pywebio/session/coroutinebased.py +++ b/pywebio/session/coroutinebased.py @@ -138,6 +138,10 @@ def send_client_event(self, event): :param dict event: 事件️消息 """ + handled = super(CoroutineBasedSession, self).send_client_event(event) + if handled: + return + coro_id = event['task_id'] coro = self.coros.get(coro_id) if not coro: diff --git a/pywebio/session/threadbased.py b/pywebio/session/threadbased.py index cac77d11..5db9960d 100644 --- a/pywebio/session/threadbased.py +++ b/pywebio/session/threadbased.py @@ -146,6 +146,10 @@ def send_client_event(self, event): :param dict event: 事件️消息 """ + handled = super(ThreadBasedSession, self).send_client_event(event) + if handled: + return + task_id = event['task_id'] mq = self.task_mqs.get(task_id) if not mq and task_id in self.callbacks: diff --git a/webiojs/src/handlers/page.ts b/webiojs/src/handlers/page.ts index 85e76fe0..cab52f40 100644 --- a/webiojs/src/handlers/page.ts +++ b/webiojs/src/handlers/page.ts @@ -10,7 +10,7 @@ export class PageHandler implements CommandHandler { handle_message(msg: Command) { if (msg.command === 'open_page') { - OpenPage(msg.spec.page_id); + OpenPage(msg.spec.page_id, msg.task_id); } else if (msg.command === 'close_page') { ClosePage(msg.spec.page_id); } diff --git a/webiojs/src/models/page.ts b/webiojs/src/models/page.ts index bba4ca93..b291860b 100644 --- a/webiojs/src/models/page.ts +++ b/webiojs/src/models/page.ts @@ -1,31 +1,81 @@ // page id to page window reference import {Command, SubPageSession} from "../session"; import {LazyPromise} from "../utils"; +import {state} from "../state"; -let subpages: { [page_id: string]: Window } = {}; +let subpages: { + [page_id: string]: { + page: Window, + task_id: string + } +} = {}; +function start_clean_up_task() { + return setInterval(() => { + let page; + for (let page_id in subpages) { + page = subpages[page_id].page; + if (page.closed || !SubPageSession.is_sub_page(page)) { + on_page_lost(page_id); + } + } + }, 1000) +} + +// page is closed accidentally +function on_page_lost(page_id: string) { + console.log(`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 + }); +} -export function OpenPage(page_id: string) { +let clean_up_task_id: number = null; + +export function OpenPage(page_id: string, task_id: string) { if (page_id in subpages) throw `Can't open page, the page id "${page_id}" is duplicated`; - subpages[page_id] = window.open(window.location.href); + + if (!clean_up_task_id) + clean_up_task_id = start_clean_up_task() + + let page = window.open(window.location.href); + subpages[page_id] = {page: page, task_id: task_id} // the `_pywebio_page` will be resolved in new opened page in `SubPageSession.start_session()` // @ts-ignore - subpages[page_id]._pywebio_page = new LazyPromise() + page._pywebio_page = new LazyPromise() + + // 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(page_id) + }, 100) + }); } 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() + subpages[page_id].page.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`; // @ts-ignore - subpages[msg.page]._pywebio_page.promise.then((page: SubPageSession) => { + subpages[msg.page].page._pywebio_page.promise.then((page: SubPageSession) => { msg.page = undefined; page.server_message(msg); }); diff --git a/webiojs/src/session.ts b/webiojs/src/session.ts index 8fa9415d..b4888516 100644 --- a/webiojs/src/session.ts +++ b/webiojs/src/session.ts @@ -65,12 +65,17 @@ export class SubPageSession implements Session { private _on_server_message: (msg: Command) => any = () => { }; - // check if it's a pywebio subpage - static is_sub_page(): boolean { + // check if the window is a pywebio subpage + static is_sub_page(window_obj: Window = window): boolean { // - `window._pywebio_page` lazy promise is not undefined // - window.opener is not null and window.opener.WebIO is not undefined - // @ts-ignore - return window._pywebio_page !== undefined && window.opener !== null && window.opener.WebIO !== undefined; + + try { + // @ts-ignore + return window_obj._pywebio_page !== undefined && window_obj.opener !== null && window_obj.opener.WebIO !== undefined; + }catch (e) { + return false; + } } on_session_create(callback: () => any): void { From 2cd9955afd1133986f2a992812f174c5cfd503f0 Mon Sep 17 00:00:00 2001 From: wangweimin Date: Sun, 26 Jun 2022 14:35:04 +0800 Subject: [PATCH 04/17] raise error when input after page close --- pywebio/io_ctrl.py | 6 ++++++ pywebio/session/base.py | 13 ++++--------- pywebio/session/coroutinebased.py | 5 ++--- pywebio/session/threadbased.py | 5 ++--- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/pywebio/io_ctrl.py b/pywebio/io_ctrl.py index ef204702..7ac03174 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__) @@ -383,6 +384,11 @@ def input_event_handle(item_valid_funcs, form_valid_funcs, preprocess_funcs, onc elif event_name == 'from_cancel': data = None break + elif event_name == 'page_close': + current_page = get_current_session().get_page_id(check_active=False) + closed_page = event_data + if closed_page == current_page: + raise PageClosedException else: logger.warning("Unhandled Event: %s", event) diff --git a/pywebio/session/base.py b/pywebio/session/base.py index 51d17aeb..abc1951e 100644 --- a/pywebio/session/base.py +++ b/pywebio/session/base.py @@ -97,7 +97,7 @@ 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): + 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 @@ -108,7 +108,7 @@ def get_page_id(self): return None page_id = self.page_stack[task_id][-1] - if page_id not in self.active_page[task_id]: + if page_id not in self.active_page[task_id] and check_active: raise PageClosedException( "The page is closed by app user, " "you see this exception mostly because you set `silent_quit=False` in `pywebio.output.page()`" @@ -150,13 +150,8 @@ def next_client_event(self) -> dict: raise NotImplementedError def send_client_event(self, event): - """send event from client to session, - return True when this event is handled by this method, which means the subclass must ignore this event. - """ - if event['event'] == 'page_close': - self.notify_page_lost(event['task_id'], event['data']) - return True - return False + """send event from client to session""" + raise NotImplementedError def get_task_commands(self) -> list: raise NotImplementedError diff --git a/pywebio/session/coroutinebased.py b/pywebio/session/coroutinebased.py index 0d754ff1..e31bfe99 100644 --- a/pywebio/session/coroutinebased.py +++ b/pywebio/session/coroutinebased.py @@ -138,9 +138,8 @@ def send_client_event(self, event): :param dict event: 事件️消息 """ - handled = super(CoroutineBasedSession, self).send_client_event(event) - if handled: - return + 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) diff --git a/pywebio/session/threadbased.py b/pywebio/session/threadbased.py index 5db9960d..06b627d6 100644 --- a/pywebio/session/threadbased.py +++ b/pywebio/session/threadbased.py @@ -146,9 +146,8 @@ def send_client_event(self, event): :param dict event: 事件️消息 """ - handled = super(ThreadBasedSession, self).send_client_event(event) - if handled: - return + 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) From 6ec21dbb1fe9d766e69f5e77f1865e5b49679ed1 Mon Sep 17 00:00:00 2001 From: wangweimin Date: Sun, 26 Jun 2022 23:24:21 +0800 Subject: [PATCH 05/17] fix scope support in page --- pywebio/session/base.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pywebio/session/base.py b/pywebio/session/base.py index abc1951e..982562c2 100644 --- a/pywebio/session/base.py +++ b/pywebio/session/base.py @@ -10,6 +10,8 @@ logger = logging.getLogger(__name__) +ROOT_SCOPE = 'ROOT' + class Session: """ @@ -62,7 +64,7 @@ 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 @@ -118,6 +120,7 @@ def get_page_id(self, check_active=True): 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() @@ -131,6 +134,7 @@ def pop_page(self): return page_id def push_page(self, page_id): + self.push_scope(ROOT_SCOPE) task_id = type(self).get_current_task_id() self.page_stack[task_id].append(page_id) self.active_page[task_id].add(page_id) From 1a80a70789ddea9947469531f69be1026276de4f Mon Sep 17 00:00:00 2001 From: wangweimin Date: Tue, 28 Jun 2022 23:19:08 +0800 Subject: [PATCH 06/17] add PageClosedException catch in `hold()` and `pin_wait_change()` --- pywebio/output.py | 2 +- pywebio/pin.py | 9 ++++++++- pywebio/session/__init__.py | 6 ++++-- pywebio/session/base.py | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/pywebio/output.py b/pywebio/output.py index 3c810dd3..dd8939e5 100644 --- a/pywebio/output.py +++ b/pywebio/output.py @@ -1855,7 +1855,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): get_current_session().pop_page() if isinstance(exc_val, PageClosedException): # page is close by app user if self.silent_quit: - # supress PageClosedException Exception + # suppress PageClosedException Exception return True else: send_msg('close_page', dict(page_id=self.page_id)) diff --git a/pywebio/pin.py b/pywebio/pin.py index f66ee1ae..2fbb05be 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,12 @@ def put_actions(name, *, label='', buttons=None, help_text=None, @chose_impl def get_client_val(): res = yield next_client_event() + if res['event'] == 'page_close': + current_page = get_current_session().get_page_id(check_active=False) + closed_page = res['data'] + if closed_page == current_page: + raise PageClosedException + 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..955e78da 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 @@ -293,7 +293,7 @@ def next_client_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 +315,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 982562c2..d19c8e30 100644 --- a/pywebio/session/base.py +++ b/pywebio/session/base.py @@ -113,7 +113,7 @@ def get_page_id(self, check_active=True): if page_id not in self.active_page[task_id] and check_active: raise PageClosedException( "The page is closed by app user, " - "you see this exception mostly because you set `silent_quit=False` in `pywebio.output.page()`" + "set `silent_quit=True` in `pywebio.output.page()` to suppress this error" ) return page_id From 08343867db1e7435c3b25983af56d5c8d1db48fc Mon Sep 17 00:00:00 2001 From: wangweimin Date: Wed, 6 Jul 2022 20:38:09 +0800 Subject: [PATCH 07/17] fix sub page session don't close when master reload or close --- webiojs/src/models/page.ts | 9 +++++++++ webiojs/src/session.ts | 23 ++++++++++++++++++++++- webiojs/src/state.ts | 2 ++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/webiojs/src/models/page.ts b/webiojs/src/models/page.ts index b291860b..18051d62 100644 --- a/webiojs/src/models/page.ts +++ b/webiojs/src/models/page.ts @@ -79,4 +79,13 @@ export function DeliverMessage(msg: Command) { msg.page = undefined; page.server_message(msg); }); +} + +export function CloseSession() { + for (let page_id in subpages) { + // @ts-ignore + subpages[page_id].page._pywebio_page.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 b4888516..4cc4be28 100644 --- a/webiojs/src/session.ts +++ b/webiojs/src/session.ts @@ -1,6 +1,7 @@ import {error_alert} from "./utils"; import {state} from "./state"; import {t} from "./i18n"; +import {CloseSession} from "./models/page"; export interface Command { command: string @@ -58,6 +59,7 @@ function safe_poprun_callbacks(callbacks: (() => void)[], name = 'callback') { export class SubPageSession implements Session { webio_session_id: string = ''; debug: boolean; + private _master_id: string; private _closed: boolean = false; private _session_create_callbacks: (() => void)[] = []; @@ -73,11 +75,17 @@ export class SubPageSession implements Session { try { // @ts-ignore return window_obj._pywebio_page !== undefined && window_obj.opener !== null && window_obj.opener.WebIO !== undefined; - }catch (e) { + } catch (e) { return false; } } + // check if the master page is active + is_master_active(): boolean { + return window.opener && window.opener.WebIO && !window.opener.WebIO._state.CurrentSession.closed() && + this._master_id == window.opener.WebIO._state.Random + } + on_session_create(callback: () => any): void { this._session_create_callbacks.push(callback); }; @@ -93,11 +101,18 @@ export class SubPageSession implements Session { start_session(debug: boolean): void { this.debug = debug; safe_poprun_callbacks(this._session_create_callbacks, 'session_create_callback'); + this._master_id = window.opener.WebIO._state.Random; // @ts-ignore window._pywebio_page.resolve(this); + + setInterval(() => { + if (!this.is_master_active()) + this.close_session(); + }, 300); }; + // called by opener, transfer command to this session server_message(command: Command) { if (this.debug) @@ -107,11 +122,15 @@ export class SubPageSession implements Session { // send text message to opener 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")); window.opener.WebIO._state.CurrentSession.send_message(msg, onprogress); } // send binary message to opener 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")); window.opener.WebIO._state.CurrentSession.send_buffer(data, onprogress); } @@ -240,6 +259,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) { @@ -367,6 +387,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); } 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 From 0b9ace1fdc21b0e7169f88b4fa2d6c5e5169212e Mon Sep 17 00:00:00 2001 From: wangweimin Date: Wed, 6 Jul 2022 23:10:23 +0800 Subject: [PATCH 08/17] fix callback have no page context --- pywebio/output.py | 4 ++-- pywebio/session/base.py | 5 +++-- pywebio/session/coroutinebased.py | 10 ++++++++-- pywebio/session/threadbased.py | 12 +++++++++--- webiojs/src/handlers/base.ts | 3 ++- webiojs/src/handlers/page.ts | 4 +++- webiojs/src/models/page.ts | 3 ++- 7 files changed, 29 insertions(+), 12 deletions(-) diff --git a/pywebio/output.py b/pywebio/output.py index dd8939e5..653121f1 100644 --- a/pywebio/output.py +++ b/pywebio/output.py @@ -1867,12 +1867,12 @@ def __call__(self, func): @wraps(func) def wrapper(*args, **kwargs): - with self: + with page_(): # can't use `with self:`, it will use same object in different calls to same decorated func return func(*args, **kwargs) @wraps(func) async def coro_wrapper(*args, **kwargs): - with self: + with page_(): return await func(*args, **kwargs) if iscoroutinefunction(func): diff --git a/pywebio/session/base.py b/pywebio/session/base.py index d19c8e30..749180b8 100644 --- a/pywebio/session/base.py +++ b/pywebio/session/base.py @@ -133,9 +133,10 @@ def pop_page(self): pass return page_id - def push_page(self, page_id): + def push_page(self, page_id, task_id=None): self.push_scope(ROOT_SCOPE) - task_id = type(self).get_current_task_id() + 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) diff --git a/pywebio/session/coroutinebased.py b/pywebio/session/coroutinebased.py index e31bfe99..74b9e4ed 100644 --- a/pywebio/session/coroutinebased.py +++ b/pywebio/session/coroutinebased.py @@ -176,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: @@ -204,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()) @@ -224,12 +225,17 @@ 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 + 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 06b627d6..fe5aebab 100644 --- a/pywebio/session/threadbased.py +++ b/pywebio/session/threadbased.py @@ -252,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): @@ -270,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): @@ -285,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): @@ -294,10 +294,16 @@ 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): + if page_id is None: + page_id = self.get_page_id() 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 + 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 af28fd67..f09a74fb 100644 --- a/webiojs/src/handlers/base.ts +++ b/webiojs/src/handlers/base.ts @@ -1,5 +1,6 @@ import {Command, Session} from "../session"; import {DeliverMessage} from "../models/page"; +import {PAGE_COMMANDS} from "./page"; export interface CommandHandler { @@ -36,7 +37,7 @@ export class CommandDispatcher { } dispatch_message(msg: Command): boolean { - if (msg.page !== undefined && msg.page) { + 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); diff --git a/webiojs/src/handlers/page.ts b/webiojs/src/handlers/page.ts index cab52f40..35a8192a 100644 --- a/webiojs/src/handlers/page.ts +++ b/webiojs/src/handlers/page.ts @@ -2,8 +2,10 @@ 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[] = ['open_page', 'close_page']; + accept_command: string[] = PAGE_COMMANDS; constructor() { } diff --git a/webiojs/src/models/page.ts b/webiojs/src/models/page.ts index 18051d62..79135180 100644 --- a/webiojs/src/models/page.ts +++ b/webiojs/src/models/page.ts @@ -65,8 +65,9 @@ export function OpenPage(page_id: string, task_id: string) { } export function ClosePage(page_id: string) { - if (!(page_id in subpages)) + if (!(page_id in subpages)) { throw `Can't close page, the page (id "${page_id}") is not found`; + } subpages[page_id].page.close(); delete subpages[page_id]; } From 3dd3ebb23f5e45c0e9589ff040b09e26ef9aef05 Mon Sep 17 00:00:00 2001 From: wangweimin Date: Thu, 7 Jul 2022 22:33:10 +0800 Subject: [PATCH 09/17] fix page open blocked --- pywebio/output.py | 2 +- webiojs/src/handlers/page.ts | 2 +- webiojs/src/i18n.ts | 8 +-- webiojs/src/models/page.ts | 97 +++++++++++++++++++++++++----------- webiojs/src/session.ts | 4 +- 5 files changed, 78 insertions(+), 35 deletions(-) diff --git a/pywebio/output.py b/pywebio/output.py index 653121f1..b686aa8e 100644 --- a/pywebio/output.py +++ b/pywebio/output.py @@ -1840,7 +1840,7 @@ def content(): class page_: page_id: str - silent_quit: bool + silent_quit: bool = False def __enter__(self): self.page_id = random_str(10) diff --git a/webiojs/src/handlers/page.ts b/webiojs/src/handlers/page.ts index 35a8192a..fc9b02ca 100644 --- a/webiojs/src/handlers/page.ts +++ b/webiojs/src/handlers/page.ts @@ -12,7 +12,7 @@ export class PageHandler implements CommandHandler { handle_message(msg: Command) { if (msg.command === 'open_page') { - OpenPage(msg.spec.page_id, msg.task_id); + OpenPage(msg.spec.page_id, msg.task_id, msg.page); } else if (msg.command === 'close_page') { ClosePage(msg.spec.page_id); } 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/models/page.ts b/webiojs/src/models/page.ts index 79135180..c95b18e0 100644 --- a/webiojs/src/models/page.ts +++ b/webiojs/src/models/page.ts @@ -1,23 +1,25 @@ // page id to page window reference import {Command, SubPageSession} from "../session"; -import {LazyPromise} from "../utils"; +import {error_alert, LazyPromise} from "../utils"; import {state} from "../state"; +import {t} from "../i18n"; let subpages: { [page_id: string]: { - page: Window, - task_id: string + page: LazyPromise, + task_id: string, + session: LazyPromise } } = {}; function start_clean_up_task() { return setInterval(() => { - let page; for (let page_id in subpages) { - page = subpages[page_id].page; - if (page.closed || !SubPageSession.is_sub_page(page)) { - on_page_lost(page_id); - } + subpages[page_id].page.promise.then((page: Window) => { + if (page.closed || !SubPageSession.is_sub_page(page)) { + on_page_lost(page_id); + } + }) } }, 1000) } @@ -39,36 +41,75 @@ function on_page_lost(page_id: string) { let clean_up_task_id: number = null; -export function OpenPage(page_id: string, task_id: string) { +export function OpenPage(page_id: string, task_id: string, parent_page: string) { 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() + clean_up_task_id = start_clean_up_task(); - let page = window.open(window.location.href); - subpages[page_id] = {page: page, task_id: task_id} + // will be resolved as new opened page + let page_promise = new LazyPromise() + + // will be resolved as SubPageSession in new opened page in `SubPageSession.start_session()` + let page_session_promise = new LazyPromise() + + subpages[page_id] = {page: page_promise, task_id: task_id, session: page_session_promise} + + let page_open_task = (parent: Window) => { + /* + * Open new page and set up the page + * */ + let page = parent.open(window.location.href); + if (page == null) { // blocked by browser + on_page_lost(page_id); + return error_alert(t("page_blocked")); + } + // @ts-ignore + page._pywebio_page = page_session_promise; + // @ts-ignore + page._pywebio_tasks = []; // the task for sub-page + page.addEventListener('message', event => { + // @ts-ignore + while (page._pywebio_tasks.length) { + // @ts-ignore + page._pywebio_tasks.shift()(page); + } + }); + + // 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(page_id) + }, 100) + }); + + page_promise.resolve(page); + } + + if (!parent_page) { + page_open_task(window); + } else { + // open the new page in currently active page, otherwise, the opening action may be blocked by browser. + subpages[parent_page].page.promise.then((opener: Window) => { + // @ts-ignore + opener._pywebio_tasks.push(page_open_task); + + // when opener receive this message, it will run the tasks in `opener._pywebio_tasks` + opener.postMessage("", "*"); + }); + } - // the `_pywebio_page` will be resolved in new opened page in `SubPageSession.start_session()` - // @ts-ignore - page._pywebio_page = new LazyPromise() - - // 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(page_id) - }, 100) - }); } 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].page.close(); + subpages[page_id].page.promise.then((page: Window) => page.close()); delete subpages[page_id]; } @@ -76,7 +117,7 @@ export function DeliverMessage(msg: Command) { if (!(msg.page in subpages)) throw `Can't deliver message, the page (id "${msg.page}") is not found`; // @ts-ignore - subpages[msg.page].page._pywebio_page.promise.then((page: SubPageSession) => { + subpages[msg.page].session.promise.then((page: SubPageSession) => { msg.page = undefined; page.server_message(msg); }); @@ -85,7 +126,7 @@ export function DeliverMessage(msg: Command) { export function CloseSession() { for (let page_id in subpages) { // @ts-ignore - subpages[page_id].page._pywebio_page.promise.then((page: SubPageSession) => { + subpages[page_id].session.promise.then((page: SubPageSession) => { page.close_session() }); } diff --git a/webiojs/src/session.ts b/webiojs/src/session.ts index 4cc4be28..474c6b01 100644 --- a/webiojs/src/session.ts +++ b/webiojs/src/session.ts @@ -69,8 +69,8 @@ export class SubPageSession implements Session { // check if the window is a pywebio subpage static is_sub_page(window_obj: Window = window): boolean { - // - `window._pywebio_page` lazy promise is not undefined - // - window.opener is not null and window.opener.WebIO is not undefined + // - `window._pywebio_page` lazy promise is defined + // - window.opener is not null and window.opener.WebIO is defined try { // @ts-ignore From 0d05d159631c64bf5b0ac907562da642817411d6 Mon Sep 17 00:00:00 2001 From: wangweimin Date: Fri, 8 Jul 2022 22:51:15 +0800 Subject: [PATCH 10/17] fix error to check master active in subpage --- pywebio/session/coroutinebased.py | 3 ++- pywebio/session/threadbased.py | 3 ++- webiojs/src/models/page.ts | 2 ++ webiojs/src/session.ts | 27 ++++++++++++++++----------- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/pywebio/session/coroutinebased.py b/pywebio/session/coroutinebased.py index 74b9e4ed..0865227e 100644 --- a/pywebio/session/coroutinebased.py +++ b/pywebio/session/coroutinebased.py @@ -235,7 +235,8 @@ def _run_async(self, coro_obj, page_id=None): self._alive_coro_cnt += 1 task = Task(coro_obj, session=self, on_coro_stop=self._on_task_finish) self.coros[task.coro_id] = task - self.push_page(page_id, task_id=task.coro_id) + 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 fe5aebab..7e53cbb7 100644 --- a/pywebio/session/threadbased.py +++ b/pywebio/session/threadbased.py @@ -303,7 +303,8 @@ def _register_thread(self, t: threading.Thread, page_id=None): self.thread2session[id(t)] = self # 用于在线程内获取会话 event_mq = queue.Queue(maxsize=self.event_mq_maxsize) # 线程内的用户事件队列 self.task_mqs[self._get_task_id(t)] = event_mq - self.push_page(page_id, task_id=self._get_task_id(t)) + 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/models/page.ts b/webiojs/src/models/page.ts index c95b18e0..ef827944 100644 --- a/webiojs/src/models/page.ts +++ b/webiojs/src/models/page.ts @@ -68,6 +68,8 @@ export function OpenPage(page_id: string, task_id: string, parent_page: string) // @ts-ignore page._pywebio_page = page_session_promise; // @ts-ignore + page._master_window = window; + // @ts-ignore page._pywebio_tasks = []; // the task for sub-page page.addEventListener('message', event => { // @ts-ignore diff --git a/webiojs/src/session.ts b/webiojs/src/session.ts index 474c6b01..e2d78a55 100644 --- a/webiojs/src/session.ts +++ b/webiojs/src/session.ts @@ -60,6 +60,7 @@ 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)[] = []; @@ -71,7 +72,6 @@ export class SubPageSession implements Session { static is_sub_page(window_obj: Window = window): boolean { // - `window._pywebio_page` lazy promise is defined // - window.opener is not null and window.opener.WebIO is defined - try { // @ts-ignore return window_obj._pywebio_page !== undefined && window_obj.opener !== null && window_obj.opener.WebIO !== undefined; @@ -82,8 +82,9 @@ export class SubPageSession implements Session { // check if the master page is active is_master_active(): boolean { - return window.opener && window.opener.WebIO && !window.opener.WebIO._state.CurrentSession.closed() && - this._master_id == window.opener.WebIO._state.Random + 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 { @@ -101,37 +102,41 @@ export class SubPageSession implements Session { start_session(debug: boolean): void { this.debug = debug; safe_poprun_callbacks(this._session_create_callbacks, 'session_create_callback'); - this._master_id = window.opener.WebIO._state.Random; + + // @ts-ignore + this._master_window = window._master_window; + this._master_id = this._master_window.WebIO._state.Random; // @ts-ignore window._pywebio_page.resolve(this); - setInterval(() => { + let check_active_id = setInterval(() => { if (!this.is_master_active()) this.close_session(); + if (this.closed()) + clearInterval(check_active_id); }, 300); }; - - // called by opener, transfer command to this session + // 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 opener + // 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")); - window.opener.WebIO._state.CurrentSession.send_message(msg, onprogress); + this._master_window.WebIO._state.CurrentSession.send_message(msg, onprogress); } - // send binary message to opener + // 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")); - window.opener.WebIO._state.CurrentSession.send_buffer(data, onprogress); + this._master_window.WebIO._state.CurrentSession.send_buffer(data, onprogress); } close_session(): void { From 6afa97b534cf090853bf5605df068afd61eb1766 Mon Sep 17 00:00:00 2001 From: wangweimin Date: Sun, 10 Jul 2022 22:17:57 +0800 Subject: [PATCH 11/17] refine page close check --- pywebio/io_ctrl.py | 5 ----- pywebio/pin.py | 5 ----- pywebio/session/__init__.py | 6 ++++-- pywebio/session/base.py | 9 +++++++++ 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/pywebio/io_ctrl.py b/pywebio/io_ctrl.py index 7ac03174..e4f2b568 100644 --- a/pywebio/io_ctrl.py +++ b/pywebio/io_ctrl.py @@ -384,11 +384,6 @@ def input_event_handle(item_valid_funcs, form_valid_funcs, preprocess_funcs, onc elif event_name == 'from_cancel': data = None break - elif event_name == 'page_close': - current_page = get_current_session().get_page_id(check_active=False) - closed_page = event_data - if closed_page == current_page: - raise PageClosedException else: logger.warning("Unhandled Event: %s", event) diff --git a/pywebio/pin.py b/pywebio/pin.py index 2fbb05be..98f05fb2 100644 --- a/pywebio/pin.py +++ b/pywebio/pin.py @@ -228,11 +228,6 @@ def put_actions(name, *, label='', buttons=None, help_text=None, @chose_impl def get_client_val(): res = yield next_client_event() - if res['event'] == 'page_close': - current_page = get_current_session().get_page_id(check_active=False) - closed_page = res['data'] - if closed_page == current_page: - raise PageClosedException assert res['event'] == 'js_yield', "Internal Error, please report this bug on " \ "https://github.com/wang0618/PyWebIO/issues" diff --git a/pywebio/session/__init__.py b/pywebio/session/__init__.py index 955e78da..790c1d34 100644 --- a/pywebio/session/__init__.py +++ b/pywebio/session/__init__.py @@ -287,8 +287,10 @@ 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(event) + return event @chose_impl diff --git a/pywebio/session/base.py b/pywebio/session/base.py index 749180b8..4904c889 100644 --- a/pywebio/session/base.py +++ b/pywebio/session/base.py @@ -154,6 +154,15 @@ def next_client_event(self) -> dict: """获取来自客户端的下一个事件。阻塞调用,若在等待过程中,会话被用户关闭,则抛出SessionClosedException异常""" raise NotImplementedError + @classmethod + def client_event_pre_check(cls, event): + """This method is called before dispatch client event""" + if event['event'] == 'page_close': + current_page = cls.get_current_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 From 44d3c6d8d7e7a67284db7a7bb9e5e484619e7c64 Mon Sep 17 00:00:00 2001 From: wangweimin Date: Sun, 10 Jul 2022 23:05:53 +0800 Subject: [PATCH 12/17] fix RecursionError in script mode --- pywebio/session/threadbased.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pywebio/session/threadbased.py b/pywebio/session/threadbased.py index 7e53cbb7..fd5997a3 100644 --- a/pywebio/session/threadbased.py +++ b/pywebio/session/threadbased.py @@ -297,12 +297,12 @@ def register_thread(self, t: threading.Thread): return self._register_thread(t) def _register_thread(self, t: threading.Thread, page_id=None): - if page_id is None: - page_id = self.get_page_id() 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)) From 91d8e1dd5b89f31d59b83db4896430f20d7264e2 Mon Sep 17 00:00:00 2001 From: wangweimin Date: Sat, 16 Jul 2022 14:39:38 +0800 Subject: [PATCH 13/17] fix client event check --- pywebio/session/__init__.py | 2 +- pywebio/session/base.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pywebio/session/__init__.py b/pywebio/session/__init__.py index 790c1d34..7fe60297 100644 --- a/pywebio/session/__init__.py +++ b/pywebio/session/__init__.py @@ -289,7 +289,7 @@ def inner(*args, **kwargs): def next_client_event(): session_ = get_current_session() event = yield session_.next_client_event() - Session.client_event_pre_check(event) + Session.client_event_pre_check(session_, event) return event diff --git a/pywebio/session/base.py b/pywebio/session/base.py index 4904c889..768dff3f 100644 --- a/pywebio/session/base.py +++ b/pywebio/session/base.py @@ -154,11 +154,11 @@ def next_client_event(self) -> dict: """获取来自客户端的下一个事件。阻塞调用,若在等待过程中,会话被用户关闭,则抛出SessionClosedException异常""" raise NotImplementedError - @classmethod - def client_event_pre_check(cls, event): + @staticmethod + def client_event_pre_check(session: "Session", event): """This method is called before dispatch client event""" if event['event'] == 'page_close': - current_page = cls.get_current_session().get_page_id(check_active=False) + current_page = session.get_page_id(check_active=False) closed_page = event['data'] if closed_page == current_page: raise PageClosedException From 7725ee680406fa4b85c8efbfeead2e668fa8a951 Mon Sep 17 00:00:00 2001 From: wangweimin Date: Sat, 16 Jul 2022 22:51:04 +0800 Subject: [PATCH 14/17] support multi-pages in single window --- pywebio/html/css/app.css | 51 ++++++++++++++++- webiojs/src/models/page.ts | 113 +++++++++++++++++++++++++++++++++++-- webiojs/src/session.ts | 24 ++++++-- 3 files changed, 177 insertions(+), 11 deletions(-) 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/webiojs/src/models/page.ts b/webiojs/src/models/page.ts index ef827944..1fd42a15 100644 --- a/webiojs/src/models/page.ts +++ b/webiojs/src/models/page.ts @@ -8,7 +8,8 @@ let subpages: { [page_id: string]: { page: LazyPromise, task_id: string, - session: LazyPromise + session: LazyPromise, + iframe: HTMLIFrameElement } } = {}; @@ -26,7 +27,7 @@ function start_clean_up_task() { // page is closed accidentally function on_page_lost(page_id: string) { - console.log(`page ${page_id} exit`); + console.debug(`page ${page_id} exit`); if (!(page_id in subpages)) // it's a duplicated call return; @@ -39,9 +40,27 @@ function on_page_lost(page_id: string) { }); } +export function NotifyPageTerminate() { + window.parent.postMessage({ + name: 'pywebio-page-close', + // @ts-ignore + page_id: window._pywebio_page_id + }, "*"); +} + +window.addEventListener('message', event => { + if (event.data.name == 'pywebio-page-close' && event.data.page_id) { + let pid = event.data.page_id; + if (!(pid in subpages)) + throw `Can't close page, the page (id "${pid}") is not found`; + remove_iframe(subpages[pid].iframe); + on_page_lost(pid); + } +}); + let clean_up_task_id: number = null; -export function OpenPage(page_id: string, task_id: string, parent_page: string) { +export function OpenPageInNewWindow(page_id: string, task_id: string, parent_page: string) { if (page_id in subpages) throw `Can't open page, the page id "${page_id}" is duplicated`; @@ -54,7 +73,7 @@ export function OpenPage(page_id: string, task_id: string, parent_page: string) // will be resolved as SubPageSession in new opened page in `SubPageSession.start_session()` let page_session_promise = new LazyPromise() - subpages[page_id] = {page: page_promise, task_id: task_id, session: page_session_promise} + subpages[page_id] = {page: page_promise, task_id: task_id, session: page_session_promise, iframe: null} let page_open_task = (parent: Window) => { /* @@ -75,7 +94,7 @@ export function OpenPage(page_id: string, task_id: string, parent_page: string) // @ts-ignore while (page._pywebio_tasks.length) { // @ts-ignore - page._pywebio_tasks.shift()(page); + page._pywebio_tasks.shift()(page); // pop first } }); @@ -104,14 +123,97 @@ export function OpenPage(page_id: string, task_id: string, parent_page: string) opener.postMessage("", "*"); }); } +} + +export function OpenPage(page_id: string, task_id: string, parent_page: string) { + 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(); + + // will be resolved as new opened page + let page_promise = new LazyPromise() + + // will be resolved as SubPageSession in new opened page in `SubPageSession.start_session()` + let page_session_promise = new LazyPromise() + + subpages[page_id] = {page: page_promise, task_id: task_id, session: page_session_promise, iframe: null} + let init_page = (page: Window) => { + /* + * Open new page and set up the page + * */ + // let page = parent.open(window.location.href); + if (page == null) { // blocked by browser + on_page_lost(page_id); + return error_alert(t("page_blocked")); + } + // @ts-ignore + page._pywebio_page_id = page_id; + // @ts-ignore + page._pywebio_page = page_session_promise; + // @ts-ignore + page._master_window = window; + // @ts-ignore + page._pywebio_tasks = []; // the task for sub-page + page.addEventListener('message', event => { + // @ts-ignore + while (page._pywebio_tasks.length) { + // @ts-ignore + page._pywebio_tasks.shift()(page); // pop first + } + }); + + // 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(page_id) + }, 100) + }); + + page_promise.resolve(page); + } + + let iframe = document.createElement("iframe"); + subpages[page_id].iframe = iframe; + iframe.classList.add('pywebio-page'); + iframe.src = location.href; + iframe.frameBorder = "0"; + + // show iframe + $('body').append(iframe); + init_page(iframe.contentWindow); + setTimeout(() => { + // show iframe + iframe.classList.add('active'); + // disable the scrollbar in body + document.documentElement.classList.add('overflow-y-hidden'); + }, 30); } +function remove_iframe(iframe: HTMLIFrameElement) { + iframe.classList.remove('active'); + setTimeout(() => { + iframe.remove(); + }, 1000); + + if ($('body > .pywebio-page.active').length == 0) + document.documentElement.classList.remove('overflow-y-hidden'); +} + +// 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].page.promise.then((page: Window) => page.close()); + if (subpages[page_id].iframe != null) { + remove_iframe(subpages[page_id].iframe) + } delete subpages[page_id]; } @@ -126,6 +228,7 @@ export function DeliverMessage(msg: Command) { } export function CloseSession() { + // close all subpage's session for (let page_id in subpages) { // @ts-ignore subpages[page_id].session.promise.then((page: SubPageSession) => { diff --git a/webiojs/src/session.ts b/webiojs/src/session.ts index e2d78a55..2b8921eb 100644 --- a/webiojs/src/session.ts +++ b/webiojs/src/session.ts @@ -1,7 +1,7 @@ import {error_alert} from "./utils"; import {state} from "./state"; import {t} from "./i18n"; -import {CloseSession} from "./models/page"; +import {CloseSession, NotifyPageTerminate} from "./models/page"; export interface Command { command: string @@ -70,14 +70,20 @@ export class SubPageSession implements Session { // check if the window is a pywebio subpage static is_sub_page(window_obj: Window = window): boolean { - // - `window._pywebio_page` lazy promise is defined - // - window.opener is not null and window.opener.WebIO is defined try { // @ts-ignore - return window_obj._pywebio_page !== undefined && window_obj.opener !== null && window_obj.opener.WebIO !== undefined; + if (window_obj._pywebio_page !== 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; } + return false; } // check if the master page is active @@ -116,6 +122,14 @@ export class SubPageSession implements 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', () => { + NotifyPageTerminate(); + }); + $('body').append(close_btn); + } }; // called by master, transfer command to this session From 832bff0c602930f8c7d65899b5144009ea46d82d Mon Sep 17 00:00:00 2001 From: wangweimin Date: Sat, 16 Jul 2022 23:35:01 +0800 Subject: [PATCH 15/17] refine code --- webiojs/src/models/page.ts | 46 ++++++++++---------------------------- webiojs/src/session.ts | 5 +++-- webiojs/src/utils.ts | 8 +++---- 3 files changed, 19 insertions(+), 40 deletions(-) diff --git a/webiojs/src/models/page.ts b/webiojs/src/models/page.ts index 1fd42a15..7db6d4ca 100644 --- a/webiojs/src/models/page.ts +++ b/webiojs/src/models/page.ts @@ -6,9 +6,9 @@ import {t} from "../i18n"; let subpages: { [page_id: string]: { - page: LazyPromise, + page: LazyPromise, task_id: string, - session: LazyPromise, + session: LazyPromise, iframe: HTMLIFrameElement } } = {}; @@ -40,24 +40,6 @@ function on_page_lost(page_id: string) { }); } -export function NotifyPageTerminate() { - window.parent.postMessage({ - name: 'pywebio-page-close', - // @ts-ignore - page_id: window._pywebio_page_id - }, "*"); -} - -window.addEventListener('message', event => { - if (event.data.name == 'pywebio-page-close' && event.data.page_id) { - let pid = event.data.page_id; - if (!(pid in subpages)) - throw `Can't close page, the page (id "${pid}") is not found`; - remove_iframe(subpages[pid].iframe); - on_page_lost(pid); - } -}); - let clean_up_task_id: number = null; export function OpenPageInNewWindow(page_id: string, task_id: string, parent_page: string) { @@ -68,10 +50,10 @@ export function OpenPageInNewWindow(page_id: string, task_id: string, parent_pag clean_up_task_id = start_clean_up_task(); // will be resolved as new opened page - let page_promise = new LazyPromise() + let page_promise = new LazyPromise() // will be resolved as SubPageSession in new opened page in `SubPageSession.start_session()` - let page_session_promise = new LazyPromise() + let page_session_promise = new LazyPromise() subpages[page_id] = {page: page_promise, task_id: task_id, session: page_session_promise, iframe: null} @@ -133,10 +115,10 @@ export function OpenPage(page_id: string, task_id: string, parent_page: string) clean_up_task_id = start_clean_up_task(); // will be resolved as new opened page - let page_promise = new LazyPromise() + let page_promise = new LazyPromise() // will be resolved as SubPageSession in new opened page in `SubPageSession.start_session()` - let page_session_promise = new LazyPromise() + let page_session_promise = new LazyPromise() subpages[page_id] = {page: page_promise, task_id: task_id, session: page_session_promise, iframe: null} @@ -155,6 +137,12 @@ export function OpenPage(page_id: string, task_id: string, parent_page: string) page._pywebio_page = page_session_promise; // @ts-ignore page._master_window = window; + // @ts-ignore + page._pywebio_page_terminate = () => { + remove_iframe(subpages[page_id].iframe); + on_page_lost(page_id); + }; + // @ts-ignore page._pywebio_tasks = []; // the task for sub-page page.addEventListener('message', event => { @@ -165,16 +153,6 @@ export function OpenPage(page_id: string, task_id: string, parent_page: string) } }); - // 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(page_id) - }, 100) - }); - page_promise.resolve(page); } diff --git a/webiojs/src/session.ts b/webiojs/src/session.ts index 2b8921eb..297d30e0 100644 --- a/webiojs/src/session.ts +++ b/webiojs/src/session.ts @@ -1,7 +1,7 @@ import {error_alert} from "./utils"; import {state} from "./state"; import {t} from "./i18n"; -import {CloseSession, NotifyPageTerminate} from "./models/page"; +import {CloseSession} from "./models/page"; export interface Command { command: string @@ -126,7 +126,8 @@ export class SubPageSession implements Session { if (window.parent != window) { // this window is in an iframe // show page close button let close_btn = $('').on('click', () => { - NotifyPageTerminate(); + // @ts-ignore + window._pywebio_page_terminate() }); $('body').append(close_btn); } diff --git a/webiojs/src/utils.ts b/webiojs/src/utils.ts index febd03a5..125bf1db 100644 --- a/webiojs/src/utils.ts +++ b/webiojs/src/utils.ts @@ -179,7 +179,7 @@ function int2bytes(num: number) { } -export class LazyPromise { +export class LazyPromise { /* * Execute operations when some the dependency is ready. * @@ -188,9 +188,9 @@ export class LazyPromise { * Mark dependency is ready: * LazyPromise.promise.resolve(dependency) * */ - public promise: Promise; - public resolve: (_: any) => void; - public reject: (_: any) => void; + public promise: Promise; + public resolve: (_: Type) => void; + public reject: (_: Type) => void; constructor() { this.promise = new Promise((resolve, reject) => { From 84a07f335aa3caff0eaf451f9982d3c72b13756e Mon Sep 17 00:00:00 2001 From: wangweimin Date: Sun, 17 Jul 2022 10:57:12 +0800 Subject: [PATCH 16/17] refactor --- webiojs/src/models/page.ts | 260 +++++++++++++++++-------------------- webiojs/src/session.ts | 13 +- 2 files changed, 129 insertions(+), 144 deletions(-) diff --git a/webiojs/src/models/page.ts b/webiojs/src/models/page.ts index 7db6d4ca..27589474 100644 --- a/webiojs/src/models/page.ts +++ b/webiojs/src/models/page.ts @@ -5,12 +5,7 @@ import {state} from "../state"; import {t} from "../i18n"; let subpages: { - [page_id: string]: { - page: LazyPromise, - task_id: string, - session: LazyPromise, - iframe: HTMLIFrameElement - } + [page_id: string]: SubPage } = {}; function start_clean_up_task() { @@ -25,88 +20,136 @@ function start_clean_up_task() { }, 1000) } -// 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; +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 + iframe: HTMLIFrameElement + + 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.new_window = args.new_window; + this.page_tasks = []; + } - 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 - }); -} + start() { + if (this.new_window) { // open sub-page in new browser window -let clean_up_task_id: number = null; -export function OpenPageInNewWindow(page_id: string, task_id: string, parent_page: string) { - if (page_id in subpages) - throw `Can't open page, the page id "${page_id}" is duplicated`; + } else { // open sub-page as iframe + this.iframe = SubPage.build_iframe(); + this.init_page(this.iframe.contentWindow); + } + } - if (!clean_up_task_id) - clean_up_task_id = start_clean_up_task(); + static build_iframe() { + let iframe = document.createElement("iframe"); + iframe.classList.add('pywebio-page'); + iframe.src = location.href; + iframe.frameBorder = "0"; - // will be resolved as new opened page - let page_promise = new LazyPromise() + // show iframe + $('body').append(iframe); + // must after the iframe is appended to DOM + setTimeout(() => { + // show iframe + iframe.classList.add('active'); + // disable the scrollbar in body + document.documentElement.classList.add('overflow-y-hidden'); + }, 30); + + return iframe; + } - // will be resolved as SubPageSession in new opened page in `SubPageSession.start_session()` - let page_session_promise = new LazyPromise() + static remove_iframe(iframe: HTMLIFrameElement) { + iframe.classList.remove('active'); + setTimeout(() => { + iframe.remove(); + }, 1000); - subpages[page_id] = {page: page_promise, task_id: task_id, session: page_session_promise, iframe: null} + if ($('body > .pywebio-page.active').length == 0) + document.documentElement.classList.remove('overflow-y-hidden'); + } - let page_open_task = (parent: Window) => { - /* - * Open new page and set up the page - * */ - let page = parent.open(window.location.href); + /* + * set up the page + * */ + private init_page(page: Window) { if (page == null) { // blocked by browser - on_page_lost(page_id); + 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: () => { + SubPage.remove_iframe(subpages[this.page_id].iframe); + on_page_lost(this.page_id); + } + } // @ts-ignore - page._pywebio_page = page_session_promise; - // @ts-ignore - page._master_window = window; - // @ts-ignore - page._pywebio_tasks = []; // the task for sub-page + page._pywebio_page_args = args; + page.addEventListener('message', event => { - // @ts-ignore - while (page._pywebio_tasks.length) { - // @ts-ignore - page._pywebio_tasks.shift()(page); // pop first + while (this.page_tasks.length) { + this.page_tasks.shift()(page); // pop first } }); - // 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(page_id) - }, 100) - }); + this.page.resolve(page); + } - page_promise.resolve(page); + close(){ + if(this.new_window){ + this.page.promise.then((page: Window) => page.close()); + }else{ + SubPage.remove_iframe(this.iframe) + } } +} - if (!parent_page) { - page_open_task(window); - } else { - // open the new page in currently active page, otherwise, the opening action may be blocked by browser. - subpages[parent_page].page.promise.then((opener: Window) => { - // @ts-ignore - opener._pywebio_tasks.push(page_open_task); - // when opener receive this message, it will run the tasks in `opener._pywebio_tasks` - opener.postMessage("", "*"); - }); - } +// 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) { if (page_id in subpages) throw `Can't open page, the page id "${page_id}" is duplicated`; @@ -114,101 +157,42 @@ export function OpenPage(page_id: string, task_id: string, parent_page: string) if (!clean_up_task_id) clean_up_task_id = start_clean_up_task(); - // will be resolved as new opened page - let page_promise = new LazyPromise() - - // will be resolved as SubPageSession in new opened page in `SubPageSession.start_session()` - let page_session_promise = new LazyPromise() - - subpages[page_id] = {page: page_promise, task_id: task_id, session: page_session_promise, iframe: null} - - let init_page = (page: Window) => { - /* - * Open new page and set up the page - * */ - // let page = parent.open(window.location.href); - if (page == null) { // blocked by browser - on_page_lost(page_id); - return error_alert(t("page_blocked")); - } - // @ts-ignore - page._pywebio_page_id = page_id; - // @ts-ignore - page._pywebio_page = page_session_promise; - // @ts-ignore - page._master_window = window; - // @ts-ignore - page._pywebio_page_terminate = () => { - remove_iframe(subpages[page_id].iframe); - on_page_lost(page_id); - }; - - // @ts-ignore - page._pywebio_tasks = []; // the task for sub-page - page.addEventListener('message', event => { - // @ts-ignore - while (page._pywebio_tasks.length) { - // @ts-ignore - page._pywebio_tasks.shift()(page); // pop first - } - }); + let parent = null; + if (parent_page) + parent = subpages[parent_page]; - page_promise.resolve(page); - } - - let iframe = document.createElement("iframe"); - subpages[page_id].iframe = iframe; - iframe.classList.add('pywebio-page'); - iframe.src = location.href; - iframe.frameBorder = "0"; - - // show iframe - $('body').append(iframe); - init_page(iframe.contentWindow); - setTimeout(() => { - // show iframe - iframe.classList.add('active'); - // disable the scrollbar in body - document.documentElement.classList.add('overflow-y-hidden'); - }, 30); + let page = new SubPage({ + page_id: page_id, + task_id: task_id, + parent: parent, + new_window: false, + }); + subpages[page_id] = page; + page.start() } -function remove_iframe(iframe: HTMLIFrameElement) { - iframe.classList.remove('active'); - setTimeout(() => { - iframe.remove(); - }, 1000); - - if ($('body > .pywebio-page.active').length == 0) - document.documentElement.classList.remove('overflow-y-hidden'); -} // 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].page.promise.then((page: Window) => page.close()); - if (subpages[page_id].iframe != null) { - remove_iframe(subpages[page_id].iframe) - } + 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`; - // @ts-ignore subpages[msg.page].session.promise.then((page: SubPageSession) => { msg.page = undefined; page.server_message(msg); }); } +// close all subpage's session export function CloseSession() { - // close all subpage's session for (let page_id in subpages) { - // @ts-ignore subpages[page_id].session.promise.then((page: SubPageSession) => { page.close_session() }); diff --git a/webiojs/src/session.ts b/webiojs/src/session.ts index 297d30e0..903aabde 100644 --- a/webiojs/src/session.ts +++ b/webiojs/src/session.ts @@ -2,6 +2,7 @@ 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 @@ -72,7 +73,7 @@ export class SubPageSession implements Session { static is_sub_page(window_obj: Window = window): boolean { try { // @ts-ignore - if (window_obj._pywebio_page !== undefined) { + if (window_obj._pywebio_page_args !== undefined) { // @ts-ignore if (window_obj.opener !== null && window_obj.opener.WebIO !== undefined) return true; @@ -110,11 +111,12 @@ export class SubPageSession implements Session { safe_poprun_callbacks(this._session_create_callbacks, 'session_create_callback'); // @ts-ignore - this._master_window = window._master_window; + let page_args:PageArgs = window._pywebio_page_args; + + this._master_window = page_args.master_window; this._master_id = this._master_window.WebIO._state.Random; - // @ts-ignore - window._pywebio_page.resolve(this); + page_args.page_session.resolve(this); let check_active_id = setInterval(() => { if (!this.is_master_active()) @@ -126,8 +128,7 @@ export class SubPageSession implements Session { if (window.parent != window) { // this window is in an iframe // show page close button let close_btn = $('').on('click', () => { - // @ts-ignore - window._pywebio_page_terminate() + page_args.on_terminate() }); $('body').append(close_btn); } From 3e647e761f273afb2f5310ace6210b13bfcfdd96 Mon Sep 17 00:00:00 2001 From: wangweimin Date: Sun, 17 Jul 2022 12:23:32 +0800 Subject: [PATCH 17/17] support both new window page and iframe page --- docs/spec.rst | 1 + pywebio/output.py | 23 +++++--- webiojs/src/handlers/page.ts | 2 +- webiojs/src/models/page.ts | 105 ++++++++++++++++++++++++++--------- 4 files changed, 96 insertions(+), 35 deletions(-) diff --git a/docs/spec.rst b/docs/spec.rst index 6baaa837..3ac53a2b 100644 --- a/docs/spec.rst +++ b/docs/spec.rst @@ -402,6 +402,7 @@ 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 ^^^^^^^^^^^^^^^ diff --git a/pywebio/output.py b/pywebio/output.py index b686aa8e..069dc174 100644 --- a/pywebio/output.py +++ b/pywebio/output.py @@ -1814,7 +1814,7 @@ async def coro_wrapper(*args, **kwargs): return wrapper -def page(silent_quit=False): +def page(new_window=False, silent_quit=False): """ Open a page. Can be used as context manager and decorator. @@ -1833,18 +1833,25 @@ def content(): input() put_xxx() """ - p = page_() - p.silent_quit = silent_quit + 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 __enter__(self): + def __init__(self, silent_quit, new_window): + self.silent_quit = silent_quit + self.new_window = new_window self.page_id = random_str(10) - send_msg('open_page', dict(page_id=self.page_id)) + + 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): @@ -1867,12 +1874,14 @@ def __call__(self, func): @wraps(func) def wrapper(*args, **kwargs): - with page_(): # can't use `with self:`, it will use same object in different calls to same decorated func + # 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 page_(): + with self.new_page(): return await func(*args, **kwargs) if iscoroutinefunction(func): diff --git a/webiojs/src/handlers/page.ts b/webiojs/src/handlers/page.ts index fc9b02ca..1e5820b0 100644 --- a/webiojs/src/handlers/page.ts +++ b/webiojs/src/handlers/page.ts @@ -12,7 +12,7 @@ export class PageHandler implements CommandHandler { handle_message(msg: Command) { if (msg.command === 'open_page') { - OpenPage(msg.spec.page_id, msg.task_id, msg.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); } diff --git a/webiojs/src/models/page.ts b/webiojs/src/models/page.ts index 27589474..30666f29 100644 --- a/webiojs/src/models/page.ts +++ b/webiojs/src/models/page.ts @@ -33,7 +33,10 @@ class SubPage { page: LazyPromise task_id: string session: LazyPromise - iframe: HTMLIFrameElement + + private iframe: HTMLIFrameElement = null; + private top: SubPage = null; + private parent: SubPage; private page_id: string; private new_window: boolean; @@ -50,54 +53,82 @@ class SubPage { 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 - this.iframe = SubPage.build_iframe(); - this.init_page(this.iframe.contentWindow); + 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() { - let iframe = document.createElement("iframe"); + static build_iframe(context: Window) { + let iframe = context.document.createElement("iframe"); iframe.classList.add('pywebio-page'); iframe.src = location.href; iframe.frameBorder = "0"; - // show iframe - $('body').append(iframe); - // must after the iframe is appended to DOM - setTimeout(() => { + // 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 - document.documentElement.classList.add('overflow-y-hidden'); - }, 30); + context.document.documentElement.classList.add('overflow-y-hidden'); + }, 10); return iframe; } - static remove_iframe(iframe: HTMLIFrameElement) { - iframe.classList.remove('active'); + remove_iframe() { + this.iframe.classList.remove('active'); setTimeout(() => { - iframe.remove(); + this.iframe.remove(); }, 1000); - if ($('body > .pywebio-page.active').length == 0) - document.documentElement.classList.remove('overflow-y-hidden'); + 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) { // blocked by browser + 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")); } @@ -107,7 +138,7 @@ class SubPage { page_session: this.session, master_window: window, on_terminate: () => { - SubPage.remove_iframe(subpages[this.page_id].iframe); + this.remove_iframe(); on_page_lost(this.page_id); } } @@ -120,14 +151,34 @@ class SubPage { } }); + // 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); } - close(){ - if(this.new_window){ + 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{ - SubPage.remove_iframe(this.iframe) + } else { + this.remove_iframe(); } } } @@ -150,14 +201,14 @@ function on_page_lost(page_id: string) { let clean_up_task_id: number = null; -export function OpenPage(page_id: string, task_id: string, parent_page: string) { +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 = null; + let parent: SubPage = null; if (parent_page) parent = subpages[parent_page]; @@ -165,7 +216,7 @@ export function OpenPage(page_id: string, task_id: string, parent_page: string) page_id: page_id, task_id: task_id, parent: parent, - new_window: false, + new_window: new_window, }); subpages[page_id] = page; page.start()