diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 500677af..8c2bd547 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -42,6 +42,21 @@ app's own agent and `tools.json`, the system toolbox, `glance.publish` and a white card or the tab bar. News's title and then Photos' headings shipped in a fixed `ink` and vanished in dark mode, so check every page you change in dark mode as well as light. +- Presentation full screen (a deck's Present, a reader's Read mode): call + `host.request("window.fullscreen", {on: true}, fn(r){…})` from the tap or + click handler that starts it, and `{on: false}` to leave. The shell refuses + a request with no press on the app in its window in the last 5 seconds + (`gesture_required:`; a press on a host sheet does not count, and after Esc + or any ending but the app's own `{on: false}` it takes a new press), one + while the window is not in front (`foreground_required:`), one from an + agent's tool call or a callback of one (`agent_call:`), and one from + anything but the app's own window (`not_a_window:`). No capability is + needed. While it presents, no screen edge brings the shell's bar, + the title bar or the dock out. Esc is the shell's key there, and ⌘F or the + window losing the focus also ends it. Define `fn on_app_fullscreen(on)` to follow both ends, + so the app leaves its Present or Read layout when the person presses Esc. A + phone answers `{applied: false, fullscreen: false, fills_screen: true}`. + The contract is in `../crates/shell/src/app_fullscreen.rs`. - Mail's service: change `apps/mail/host-service` and run `cargo test --locked -p octosense-mail-service`. Calendar's and News's: `apps/calendar/host-service`, `apps/news/host-service`, and diff --git a/apps/README.md b/apps/README.md index c40819ef..003caeeb 100644 --- a/apps/README.md +++ b/apps/README.md @@ -529,6 +529,36 @@ isolate under no app's policy. Calls from the sheet arrive marked they come from the sheet, before any service sees them; - only a service can open a sheet; an app cannot. +### The `window` service + +The shell's own service for an app's window. `window.fullscreen {on: true}` +puts the app's own window in presentation full screen when the person taps or +clicks something in it (a deck's Present, a reader's Read mode), and +`{on: false}` takes it out. On a desktop the window then covers the whole desk +with no title bar, the shell's bar and dock hide (no screen edge brings them +out while it presents), and the OctoSense window enters full screen unless it +is already there. **Esc** ends it (the shell's +key: a script app gets no keys), as do `{on: false}`, ⌘F, the window losing +the focus and the app failing, and the shell shows "Press Esc to exit full +screen" for three seconds. The answer is `{applied, fullscreen, fills_screen}`. +A refusal starts with `gesture_required:` (no press on the app in its window +in the last 5 seconds; a press on a host sheet over the app does not count), +`foreground_required:` (the window is not in front: the focus is elsewhere, +or it is on another workspace or minimized), `agent_call:` (the request came +from an agent's tool call or from a callback of one), `not_a_window:` (a +glance card, a sheet or another app's window) or `invalid_arguments:`. +`{on: false}` is never refused for the first three. No capability is needed: +`prompt`, like every declaration, neither grants nor denies the method. After +a presentation ends any other way than the app's own `{on: false}` (Esc, ⌘F, +the focus leaving), going full screen again needs a new press, so an app +that asks again from `on_app_fullscreen(false)` is refused. An app may define +`fn on_app_fullscreen(on)`, which the shell calls in the app's isolate when +full screen starts and when it ends. On +a phone, where every app fills the screen, nothing changes: +`{applied: false, fullscreen: false, fills_screen: true}`. The service is the +shell's (`crates/shell/src/app_fullscreen.rs`), not App Hub's, and +`runtime.list` does not list it yet. + ### The `mail` service Ordinary App Hub apps now have account-bound compose/status APIs and native SMTP review. See the [Mail service guide and migration example](mail/host-service/README.md); direct agent or synthetic sending remains refused. diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md index b3a27220..e28c1f9a 100644 --- a/apps/README.zh-CN.md +++ b/apps/README.zh-CN.md @@ -431,6 +431,26 @@ python3 apps/writer/tests/ui.py --card-host /target/release/card-host - `mail.sheet.cancel`),只有来自面板的调用才会被分发,且在任何服务看到之前就已检查; - 只有服务能打开面板,应用不能。 +### `window` 服务 + +Shell 自己为应用窗口提供的服务。用户点按或点击应用窗口中的某个控件时(演示文稿的 Present、 +阅读器的 Read 模式),`window.fullscreen {on: true}` 让应用自己的窗口进入演示全屏, +`{on: false}` 让它退出。在桌面上,窗口随即铺满整个桌面且没有标题栏,Shell 的栏和程序坞 +隐藏(演示期间屏幕边缘也不会把它们唤出),OctoSense 窗口如果还不在全屏中也会进入全屏。按 **Esc** 结束(这是 Shell 的按键: +脚本应用收不到按键),`{on: false}`、⌘F、窗口失去焦点以及应用出错也会结束它;Shell 会显示 +"Press Esc to exit full screen" 三秒。回答是 `{applied, fullscreen, fills_screen}`。 +拒绝以下列前缀开头:`gesture_required:`(最近 5 秒内没有在这个窗口里按过应用本身;在应用上方 +的宿主面板里按下不算)、`foreground_required:`(窗口不在最前:焦点在别处,或窗口在另一个 +工作区、已最小化)、`agent_call:`(请求来自 Agent 的工具调用或它的回调)、`not_a_window:` +(glance 卡片、面板或其他应用的窗口)、`invalid_arguments:`。`{on: false}` 不会因为前三种 +原因被拒绝。不需要任何能力:`prompt` 和其他声明一样,既不授予也不拒绝这个方法。演示全屏以 +应用自己的 `{on: false}` 以外的方式结束后(Esc、⌘F、焦点离开),再次进入全屏需要新的一次 +按下,所以应用在 `on_app_fullscreen(false)` 里再次请求会被拒绝。应用可以定义 `fn on_app_fullscreen(on)`, +全屏开始和结束时 Shell 都会在应用的 isolate 中调用它。在手机上,每个应用本来就占满屏幕, +所以什么都不会改变:`{applied: false, fullscreen: false, fills_screen: true}`。这个服务 +属于 Shell(`crates/shell/src/app_fullscreen.rs`),不属于 App Hub,`runtime.list` 目前还 +不列出它。 + ### `mail` 服务 普通 App Hub 应用现可使用按应用/账户隔离的撰写、状态查询和原生 SMTP 审阅接口。见 [Mail 服务与迁移示例](mail/host-service/README.zh-CN.md);Agent 和合成输入仍不能直接批准发送。 diff --git a/crates/shell/src/app_fullscreen.rs b/crates/shell/src/app_fullscreen.rs new file mode 100644 index 00000000..3faa6386 --- /dev/null +++ b/crates/shell/src/app_fullscreen.rs @@ -0,0 +1,1146 @@ +//! Presentation full screen: an app's own window over the whole screen, at +//! the person's tap (`window.fullscreen`). +//! +//! A script app asks from the handler of a tap or click, and may say what it +//! does when full screen starts and ends: +//! +//! ```text +//! host.request("window.fullscreen", {on: true}, fn(r) { +//! // r.is_ok: r.data is {applied, fullscreen, fills_screen} +//! }) +//! fn on_app_fullscreen(on) { … } // optional +//! ``` +//! +//! On a desktop style the shell makes the app's window full screen, as ⌘F +//! and the green button do (`DesktopWindow::fullscreen`, tiled the +//! workspace's SUPER+F fullscreen; `WmLayout::set_fullscreen` names the +//! window): it covers the whole desk with no title bar, and the bar and the +//! dock hide. Unlike ⌘F's full screen, no edge brings them back while the +//! app presents (`WmState::presentation`: the top edge, the bottom edge and +//! every style's dock or taskbar stay away, as in a slideshow's play mode). +//! The shell's own window enters the host's full screen when it is not there +//! already ([`HostFullscreen`]), and the shell says "Press Esc to exit full +//! screen" for a few seconds (`OsdShow::fullscreen_hint`). Esc (the shell's +//! key: a script app gets no keys), the app's `{on: false}`, ⌘F, the window +//! closing or losing the focus, the app failing, a style switch, or the +//! person leaving the host's full screen ends it and puts everything back as +//! it was: the window's rect and its place in the stack (never changed while +//! it was full screen), the bar and the dock, and the host window, which +//! leaves full screen only if this presentation took it there, even when it +//! arrives there late ([`PENDING_LEAVE_SECONDS`]). The app hears both ends +//! through its optional `on_app_fullscreen(on)` hook, called in its own +//! isolate as `on_app_resize` is. On a phone every app already fills the +//! screen and the shell has no immersive mode: the request changes nothing +//! and says so. +//! +//! **Its own window only.** The shell finds the window whose Card runner +//! runs the isolate that asked, for the app that asked (`App::window_of`, +//! [`window_for`]), so an app reaches no other app's window, and a surface +//! that is not an app window (a glance card, a host sheet) is refused +//! `not_a_window`. +//! +//! **From a tap.** makepad's runtime cannot say whether a `host.request` +//! came from a person's tap. Input provenance (`trusted_user_input`: macOS +//! HID pointers and Android touches only) holds only during the native +//! event's own handler, and a Splash `on_click` runs later, from the +//! widget-to-script queue, outside it. So the shell keeps a transient +//! activation, as a browser does: a press on the app's own content in that +//! window (`MpRunViewAction::Pressed`: the module's live root with no host +//! service's sheet up over it, never a group tab, a stopped app's face or a +//! process tile) within [`ACTIVATION_SECONDS`] before the request, used up +//! by a request that applies ([`Activation`]). A presentation that ends any +//! other way than the app's own `{on: false}` (Esc, ⌘F, the focus leaving) +//! drops the window's press as well ([`Presenter::finish`]): an app that +//! asks again from its `on_app_fullscreen(false)` hook needs a new press, so +//! a click that turned a slide cannot take it straight back. Any press +//! counts, remote and synthetic ones too: a script app cannot make input. +//! Esc and the hint are the rest of the protection. +//! +//! **Not from an agent.** Every request carries its surface's authority +//! (`ServiceCall::may_prompt`). App Hub's Card runner gives every app's +//! window that authority, whatever its manifest declares +//! (`IsolateSettings::host_prompts`; the `prompt` capability is a +//! declaration, `AppPolicy::may_prompt`, and nothing on a request's path +//! reads it), and takes it away while one of the app's agent tool calls +//! runs, and while the app is suspended, which only happens on a phone. A +//! callback keeps the authority of the request it answers. So a request +//! without it came from an agent's tool call or a callback of one, and +//! `{on: true}` is refused `agent_call`; a window not in front (the focus +//! elsewhere, another workspace, minimized) is refused `foreground_required`. +//! `{on: false}` needs neither: an app may always give the screen back. A +//! test (`no_capability_decides_a_requests_authority`) holds App Hub to +//! this, so a pin that tied the authority to a capability would fail here +//! first. + +use crate::layout::ClientId; +use makepad_widgets::widget_async::SplashVmId; +use makepad_widgets::{KeyCode, KeyModifiers}; +use serde_json::{json, Value}; +use std::collections::HashMap; + +/// The host-service family: `window.fullscreen`. +pub const FAMILY: &str = "window"; +/// How long after the person's press in a window that window may still go +/// full screen: a browser's transient activation lasts about as long. +pub const ACTIVATION_SECONDS: f64 = 5.0; +/// Requests waiting for the shell's next turn, from every app together. +pub const MAX_QUEUED: usize = 16; +/// The log gets at most one line per app this often for requests that +/// changed nothing (refusals among them), and counts the rest. +pub const QUIET_LOG_SECONDS: f64 = 10.0; +/// How long a leave waits, once a presentation ended while the host window +/// was still entering full screen, for the window to say it is in. After +/// that the enter is taken never to have come (macOS ignores one asked for +/// while the window is still leaving the last full screen), and a full +/// screen the window reports is the person's. +pub const PENDING_LEAVE_SECONDS: f64 = 20.0; + +pub const NOT_A_WINDOW: &str = "not_a_window: only the app's own window can go full screen"; +pub const FOREGROUND_REQUIRED: &str = + "foreground_required: the app's window must be in front: focused, on the workspace in view, not minimized"; +pub const AGENT_CALL: &str = + "agent_call: not from an agent's tool call, or from a callback of one: ask from the person's tap or click"; +pub const GESTURE_REQUIRED: &str = "gesture_required: ask for full screen from the person's tap or click in the app's window"; +pub const UNAVAILABLE: &str = "unavailable: this window cannot cover the desk in this layout"; +pub const BUSY: &str = "busy: too many full-screen requests are waiting"; + +/// A `window.*` request, checked before it reaches the shell's turn: the +/// method, the surface and the arguments. The answer is `on`. Whether the +/// request may go full screen is the shell's turn to decide ([`decide`]), +/// with the window in view. +pub fn admit(service: &str, from_sheet: bool, args: &Value) -> Result { + if service != "window.fullscreen" { + return Err(format!("method_unavailable: the window service has fullscreen {{on}} only, not {service:?}")); + } + if from_sheet { + return Err(NOT_A_WINDOW.into()); + } + parse_on(args) +} + +/// `{on: true}` or `{on: false}`, nothing else. +pub fn parse_on(args: &Value) -> Result { + let wrong = || "invalid_arguments: expected {on: true} or {on: false}".to_string(); + let object = args.as_object().ok_or_else(wrong)?; + if object.len() != 1 { + return Err(wrong()); + } + object.get("on").and_then(Value::as_bool).ok_or_else(wrong) +} + +/// The answer: whether this request changed anything, whether the window now +/// covers the desk in full screen, and whether it fills all the room the +/// shell gives apps (while it covers the desk; always on a phone, where every +/// app does). It does not say whether the host window reached the OS's full +/// screen: that follows a moment later, or not at all in a hidden run. +pub fn answer(applied: bool, fullscreen: bool, fills_screen: bool) -> Value { + json!({"applied": applied, "fullscreen": fullscreen, "fills_screen": fills_screen}) +} + +/// A window, as far as finding the one a request is for goes: its client, +/// the script app it runs (`ClientSlot::foreground_app`) and the isolate of +/// its Card runner, when it has one. +pub struct WindowIdentity { + pub client: ClientId, + pub app: Option, + pub isolate: Option, +} + +/// The window of the app `app` whose Card runner runs `isolate`: the one +/// window a request from that isolate may change. None for any other +/// surface: another app's window, a glance card, a sheet. +pub fn window_for(app: &str, isolate: usize, windows: &[WindowIdentity]) -> Option { + windows + .iter() + .find(|w| w.app.as_deref() == Some(app) && w.isolate == Some(isolate)) + .map(|w| w.client) +} + +/// What the shell knows when a request arrives. +#[derive(Clone, Debug, Default)] +pub struct Facts { + /// A phone style is up: apps fill the screen already. + pub mobile: bool, + /// The requesting app's own window ([`window_for`]). + pub window: Option, + /// The window presenting now, if any. + pub presenting: Option, + /// The window is full screen already (⌘F, the green button, + /// `SetFullscreen`). + pub covering: bool, + /// The window has the focus, is not minimized, and is on the workspace + /// in view. + pub in_front: bool, + /// The request came without its surface's authority + /// (`ServiceCall::may_prompt`): from an agent's tool call, or from a + /// callback of one (see the module notes). + pub agent_call: bool, + /// The person pressed the app's content in it within + /// [`ACTIVATION_SECONDS`]. + pub pressed: bool, +} + +/// What the shell does with a request. +#[derive(Clone, Debug, PartialEq)] +pub enum Decision { + /// Answer at once, changing nothing. + Answer(Value), + Refuse(&'static str), + /// Start presenting this window. + Begin(ClientId), + /// End this window's presentation. + End(ClientId), +} + +/// The decision for a request to turn full screen `on` or off. +pub fn decide(on: bool, facts: &Facts) -> Decision { + if facts.mobile { + return Decision::Answer(answer(false, false, true)); + } + let Some(client) = facts.window else { + return Decision::Refuse(NOT_A_WINDOW); + }; + if !on { + return if facts.presenting == Some(client) { + Decision::End(client) + } else { + Decision::Answer(answer(false, facts.covering, facts.covering)) + }; + } + if facts.presenting == Some(client) { + return Decision::Answer(answer(false, true, true)); + } + if !facts.in_front { + return Decision::Refuse(FOREGROUND_REQUIRED); + } + if facts.agent_call { + return Decision::Refuse(AGENT_CALL); + } + if !facts.pressed { + return Decision::Refuse(GESTURE_REQUIRED); + } + Decision::Begin(client) +} + +/// The presses on apps' content in their windows, for the transient +/// activation a request needs (see the module notes). +#[derive(Debug, Default)] +pub struct Activation { + presses: HashMap, +} + +impl Activation { + /// The person pressed in `client`'s window at `now` (seconds). Presses + /// too old to count go, so closed windows leave nothing behind. + pub fn press(&mut self, client: ClientId, now: f64) { + self.presses.retain(|_, at| now >= *at && now - *at <= ACTIVATION_SECONDS); + self.presses.insert(client, now); + } + /// Whether `client` was pressed within [`ACTIVATION_SECONDS`] before `now`. + pub fn fresh(&self, client: ClientId, now: f64) -> bool { + self.presses.get(&client).is_some_and(|at| now >= *at && now - *at <= ACTIVATION_SECONDS) + } + /// `client`'s press counts no more: a request that applied used it up, + /// or the window's presentation ended without the app asking. + pub fn clear(&mut self, client: ClientId) { + self.presses.remove(&client); + } +} + +/// What the shell asks of its own window. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostOp { + Enter, + Leave, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum HostState { + /// Nothing of a presentation's: the host window is as the person left it. + #[default] + Idle, + /// Asked to enter full screen; it has not said that it did. `leave`: the + /// presentation ended meanwhile, at that time, so the window's next + /// report decides: in, however late, and it leaves; not in, and there is + /// nothing to give back (macOS reports a failed enter too). Past + /// [`PENDING_LEAVE_SECONDS`] the leave is dropped. + Entering { leave: Option }, + /// In full screen because a presentation asked. + Entered, + /// Already in full screen when the presentation began: it stays so. + AlreadyFull, + /// Not asked: the windows are hidden (`launch_input::hidden_windows`). + Skipped, +} + +/// The host window's full screen around a presentation: entered only when it +/// was not full screen already, left only if the presentation entered it. +/// makepad's macOS backend has one toggle for both directions +/// (`toggleFullScreen:`), so the shell asks it to leave only while it reports +/// full screen, and one still entering leaves once it says it is in. +#[derive(Debug, Default)] +pub struct HostFullscreen { + state: HostState, +} + +impl HostFullscreen { + /// A presentation begins at `now`, the host window `fullscreen` or not; + /// `may_ask`: the shell may ask it to enter (its windows are shown). + pub fn begin(&mut self, fullscreen: bool, may_ask: bool, now: f64) -> Option { + self.expire(now); + if let HostState::Entering { .. } = self.state { + // The enter asked for before is still on its way: it serves this + // one, and a second toggle would take the window back out. + self.state = HostState::Entering { leave: None }; + return None; + } + self.state = if fullscreen { + HostState::AlreadyFull + } else if !may_ask { + HostState::Skipped + } else { + HostState::Entering { leave: None } + }; + matches!(self.state, HostState::Entering { .. }).then_some(HostOp::Enter) + } + + /// The host window reports at `now` whether it is full screen. The op + /// to send, and whether the person took it out of a full screen a + /// presentation relies on (which ends the presentation). + pub fn reported(&mut self, fullscreen: bool, now: f64) -> (Option, bool) { + self.expire(now); + match (self.state, fullscreen) { + // In after its presentation ended, however late: it goes back out. + (HostState::Entering { leave: Some(_) }, true) => { + self.state = HostState::Idle; + (Some(HostOp::Leave), false) + } + // Not in after its presentation ended: the enter failed or never + // came, and nothing is left to give back. + (HostState::Entering { leave: Some(_) }, false) => { + self.state = HostState::Idle; + (None, false) + } + (HostState::Entering { leave: None }, true) => { + self.state = HostState::Entered; + (None, false) + } + (HostState::Entered | HostState::AlreadyFull, false) => { + self.state = HostState::Idle; + (None, true) + } + _ => (None, false), + } + } + + /// A presentation ends at `now`, the host window `fullscreen` or not; + /// `toggles`: entering and leaving are one toggle (macOS). + pub fn end(&mut self, fullscreen: bool, toggles: bool, now: f64) -> Option { + let op = match self.state { + HostState::Entered if fullscreen || !toggles => Some(HostOp::Leave), + // Still on its way in: a toggle now would only turn it round in + // the middle, so the window's next report decides, for a while. + HostState::Entering { .. } if toggles => { + self.state = HostState::Entering { leave: Some(now) }; + return None; + } + HostState::Entering { .. } => Some(HostOp::Leave), + _ => None, + }; + self.state = HostState::Idle; + op + } + + /// A leave that waited longer than [`PENDING_LEAVE_SECONDS`] for an + /// enter is dropped: whatever the window reports next is the person's. + fn expire(&mut self, now: f64) { + if let HostState::Entering { leave: Some(at) } = self.state { + if !(now >= at && now - at <= PENDING_LEAVE_SECONDS) { + self.state = HostState::Idle; + } + } + } +} + +/// One app window presenting. +#[derive(Clone, Debug)] +pub struct Presentation { + pub client: ClientId, + pub app: String, + pub isolate: usize, + /// The instance presenting: one that takes its place in the window does + /// not inherit the presentation. + pub instance: SplashVmId, + /// It was full screen before (⌘F, the green button): it stays full + /// screen afterwards. + pub was_covering: bool, +} + +/// Why a presentation ended, for the log. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Ending { + /// The person pressed Esc. + Escape, + /// The app asked (`{on: false}`). + App, + /// Its window closed, or another instance took its place. + Closed, + /// Its app failed: the module panicked, and the window shows the closed + /// face with Restart. + Failed, + /// It is no longer full screen: ⌘F took it out. + Uncovered, + /// Another window or workspace took the focus, or it was minimized. + FocusLeft, + /// The person took the host window out of full screen. + HostLeft, + /// A phone style came up. + Style, +} + +/// What ends a presentation that no longer holds, checked after every event: +/// its window is `alive` (open, with the instance that began presenting), +/// its app has not `failed`, a phone style is not up (`mobile`), it still +/// `covers` the desk and has the focus (`focused`). +pub fn ending_for(alive: bool, failed: bool, mobile: bool, covers: bool, focused: bool) -> Option { + if !alive { + Some(Ending::Closed) + } else if failed { + Some(Ending::Failed) + } else if mobile { + Some(Ending::Style) + } else if !covers { + Some(Ending::Uncovered) + } else if !focused { + Some(Ending::FocusLeft) + } else { + None + } +} + +/// Whether a key ends the presentation: a plain Esc, the shell's own while +/// one is on. ⌃Esc stays the launcher's. +pub fn esc_ends(key: KeyCode, modifiers: &KeyModifiers, presenting: bool) -> bool { + presenting && key == KeyCode::Escape && !(modifiers.shift || modifiers.control || modifiers.alt || modifiers.logo) +} + +/// The log lines for requests that changed nothing, refusals among them: at +/// most one per app every [`QUIET_LOG_SECONDS`], so an app that asks in a +/// loop cannot flood the log. +#[derive(Debug, Default)] +pub struct QuietLog { + /// app -> when its last line was written, and the requests since that + /// were not. + apps: HashMap, +} + +impl QuietLog { + /// A request of `app`'s that changed nothing, at `now`: `Some(n)` to + /// write its line, saying `n` more went unwritten since the last one; + /// `None` to count it. + pub fn note(&mut self, app: &str, now: f64) -> Option { + if let Some((at, unwritten)) = self.apps.get_mut(app) { + if now >= *at && now - *at < QUIET_LOG_SECONDS { + *unwritten += 1; + return None; + } + } + Some(self.apps.insert(app.to_string(), (now, 0)).map_or(0, |(_, unwritten)| unwritten)) + } +} + +/// The shell's part: the presses, the presentation, the host window. +#[derive(Debug, Default)] +pub struct Presenter { + pub activation: Activation, + pub session: Option, + pub host: HostFullscreen, + pub quiet: QuietLog, +} + +impl Presenter { + /// The person pressed the app's content in `client`'s window at `now`. + pub fn press(&mut self, client: ClientId, now: f64) { + self.activation.press(client, now); + } + + /// Whether `client`'s window has a press to go full screen with at `now`. + pub fn pressed(&self, client: ClientId, now: f64) -> bool { + self.activation.fresh(client, now) + } + + /// `presentation` begins, and uses its window's press up. + pub fn start(&mut self, presentation: Presentation) { + self.activation.clear(presentation.client); + self.session = Some(presentation); + } + + /// The presentation ends, for `ending`. Unless the app itself asked, its + /// window's press goes too: a press made while it presented (a click + /// that turned a slide) must not take it straight back to full screen + /// when the app asks again, say from `on_app_fullscreen(false)`. + pub fn finish(&mut self, ending: Ending) -> Option { + let session = self.session.take()?; + if ending != Ending::App { + self.activation.clear(session.client); + } + Some(session) + } +} + +#[cfg(any(feature = "app-hub", native_mobile))] +pub use service::{register, take_requests, Request}; + +#[cfg(any(feature = "app-hub", native_mobile))] +mod service { + use super::*; + use makepad_widgets::makepad_platform::SignalToUI; + use octosense_appstore::services::{self, HostService, Replier, ServiceCall, ServiceHost}; + use std::sync::Mutex; + + /// A request for the shell's next turn (`App::serve_window_requests`): + /// the app and the isolate that asked, what it asked, whether it came + /// without its surface's authority, and where the answer goes. + pub struct Request { + pub app: String, + pub isolate: usize, + pub on: bool, + /// `!ServiceCall::may_prompt`: an agent's tool call, or a callback + /// of one (the module notes). + pub agent_call: bool, + pub reply: Replier, + } + + static QUEUE: Mutex> = Mutex::new(Vec::new()); + + /// Offer `window.fullscreen` to every app. It describes no method to + /// App Hub's discovery yet: App Hub's contract knows no capability a + /// window method could name (`KNOWN_CAPABILITIES`), and a description + /// naming another would be refused. + pub fn register() { + services::register_host_service(Box::new(WindowService)); + } + + /// The requests waiting, taken off the queue. + pub fn take_requests() -> Vec { + std::mem::take(&mut *QUEUE.lock().unwrap_or_else(|e| e.into_inner())) + } + + struct WindowService; + + impl HostService for WindowService { + fn family(&self) -> &'static str { + FAMILY + } + + fn call(&mut self, call: ServiceCall, reply: Replier, _host: &mut dyn ServiceHost) { + match admit(&call.service, call.from_sheet, &call.args) { + Ok(on) => { + let mut queue = QUEUE.lock().unwrap_or_else(|e| e.into_inner()); + if queue.len() >= MAX_QUEUED { + drop(queue); + reply.send(Err(BUSY.into())); + return; + } + queue.push(Request { app: call.app_id, isolate: reply.isolate_key(), on, agent_call: !call.may_prompt, reply }); + drop(queue); + // The shell answers at the end of this event, or the next. + SignalToUI::set_ui_signal(); + } + Err(refusal) => reply.send(Err(refusal)), + } + } + } +} + +use crate::*; + +/// Whether a host service's sheet is up over a card app: App Hub's Card +/// runner draws one (a sign-in, a wizard) as the Splash `sheet` over the +/// app, as `mobile_back::card_sheet_back` finds it. A press there is the +/// host's, not the app's. Run inside the module's isolate. +pub(crate) fn host_sheet_up(cx: &mut Cx, root: &WidgetRef) -> bool { + root.splash(cx, ids!(sheet)).borrow().is_some_and(|sheet| sheet.view.visible) +} + +impl App { + /// The person pressed the app's own content in `client`'s window + /// (`MpRunViewAction::Pressed`): for the next few seconds it may go full + /// screen. + pub(crate) fn note_window_press(&mut self, client: ClientId) { + self.presenter.press(client, host::now()); + } + + /// The `window.fullscreen` requests waiting, answered: after the event + /// that pumped them, on the UI thread. + #[cfg(any(feature = "app-hub", native_mobile))] + pub(crate) fn serve_window_requests(&mut self, cx: &mut Cx) { + for request in take_requests() { + if !request.reply.is_pending() { + continue; + } + let result = self.window_request(cx, &request); + let applied = matches!(&result, Ok(value) if value["applied"] == true); + // A request that changed something needed a press or ended a + // presentation, so it cannot come in a flood; one that changed + // nothing can. + let unwritten = if applied { Some(0) } else { self.presenter.quiet.note(&request.app, host::now()) }; + if let Some(unwritten) = unwritten { + let more = if unwritten > 0 { format!(" ({unwritten} more since the last such line)") } else { String::new() }; + match &result { + Ok(value) => log!("wm: window.fullscreen {{on: {}}} from {}: {}{}", request.on, request.app, value, more), + Err(error) => log!("wm: window.fullscreen {{on: {}}} from {} refused: {}{}", request.on, request.app, error, more), + } + } + request.reply.send(result); + } + } + + #[cfg(any(feature = "app-hub", native_mobile))] + fn window_request(&mut self, cx: &mut Cx, request: &Request) -> Result { + let mobile = self.state.as_ref().is_none_or(|s| s.style.target.mobile()); + let window = if mobile { None } else { self.window_of(cx, &request.app, request.isolate) }; + let now = host::now(); + let facts = match (self.state.as_ref(), window) { + (Some(state), Some(client)) => { + let layout = &state.layout; + Facts { + mobile, + window, + presenting: self.presenter.session.as_ref().map(|s| s.client), + covering: layout.is_fullscreen(client), + in_front: layout.focused_client() == Some(client) + && layout.workspace_of(client) == Some(layout.active) + && !layout.desktop.minimized(client), + agent_call: request.agent_call, + pressed: self.presenter.pressed(client, now), + } + } + _ => Facts { mobile, ..Facts::default() }, + }; + match decide(request.on, &facts) { + Decision::Answer(value) => Ok(value), + Decision::Refuse(refusal) => Err(refusal.into()), + Decision::End(client) => { + self.end_presentation(cx, Ending::App); + // Still full screen when the person had made it so (⌘F) first. + let full = self.state.as_ref().is_some_and(|s| s.layout.is_fullscreen(client)); + Ok(answer(true, full, full)) + } + Decision::Begin(client) => { + if self.begin_presentation(cx, client, &request.app, request.isolate) { + Ok(answer(true, true, true)) + } else { + Err(UNAVAILABLE.into()) + } + } + } + } + + /// The window of script app `app` whose Card runner runs `isolate`. A + /// failed instance's window has no app in it to ask. + fn window_of(&mut self, cx: &mut Cx, app: &str, isolate: usize) -> Option { + let candidates: Vec<(ClientId, Option)> = self + .state + .as_ref()? + .clients + .iter() + .filter(|(client, slot)| { + self.module_host.is_module(**client) + && !self.module_host.is_failed(**client) + && slot.foreground_app().as_deref() == Some(app) + }) + .map(|(client, slot)| (*client, slot.foreground_app())) + .collect(); + let windows: Vec = candidates + .into_iter() + .map(|(client, app)| { + // The Card runner's own surface, as `MpModuleView` finds it. + let isolate = self + .module_host + .dispatch(cx, client, "its window identity", |cx, root| root.splash(cx, ids!(card)).isolate_heap_key(cx)) + .flatten(); + WindowIdentity { client, app, isolate } + }) + .collect(); + window_for(app, isolate, &windows) + } + + /// `client` goes full screen with no edge reveal, and the host window + /// goes full screen; false when it cannot in this layout. + fn begin_presentation(&mut self, cx: &mut Cx, client: ClientId, app: &str, isolate: usize) -> bool { + // Another window's presentation this event has not ended yet (its + // window lost the focus to this one) ends first. + if self.presenter.session.as_ref().is_some_and(|s| s.client != client) { + self.end_presentation(cx, Ending::FocusLeft); + } + let Some(instance) = self.module_host.get(client).map(|i| i.vm_id) else { + return false; + }; + let Some(state) = self.state.as_mut() else { + return false; + }; + let was_covering = state.layout.is_fullscreen(client); + if !was_covering && !state.layout.set_fullscreen(client, true) { + return false; + } + // No edge brings the bar, the title bar or the dock out while it + // presents; whatever was out goes back now. + state.presentation = Some(client); + self.top_edge.set(cx, false); + self.presenter.start(Presentation { client, app: app.to_string(), isolate, instance, was_covering }); + let window = self.ui.window(cx, ids!(main_window)); + let fullscreen = window.is_fullscreen(cx); + let may_ask = !crate::launch_input::hidden_windows(); + let op = self.presenter.host.begin(fullscreen, may_ask, host::now()); + if fullscreen { + log!("wm: app full screen: {app} (client {client}); the host window is full screen already and stays so"); + } else if !may_ask { + log!("wm: app full screen: {app} (client {client}); the host window is not asked to enter full screen: windows are hidden (MAKEPAD_HIDE_WINDOWS)"); + } else { + log!("wm: app full screen: {app} (client {client}); the host window enters full screen"); + } + self.host_window_op(cx, op); + // The bar hides now, tiled or floating (the draw that follows sees a + // floating window go full screen too). + self.sync_bar_for_fullscreen(cx); + self.show_osd(cx, shell::osd::OsdShow::fullscreen_hint()); + self.tell_app_fullscreen(cx, client, true); + self.update_bar(cx); + self.redraw_all(cx); + true + } + + /// End the presentation, if one is on, and put everything back as it was. + pub(crate) fn end_presentation(&mut self, cx: &mut Cx, ending: Ending) { + let Some(session) = self.presenter.finish(ending) else { + return; + }; + // The window leaves full screen, unless it was full screen before + // (⌘F): its rect and its place in the stack never changed. In both + // layouts, in case the style switched. The edges work again. + if let Some(state) = self.state.as_mut() { + state.presentation = None; + if !session.was_covering { + state.layout.clear_fullscreen(session.client); + } + } + // The host window leaves full screen only if this presentation took it there. + let window = self.ui.window(cx, ids!(main_window)); + let op = self.presenter.host.end(window.is_fullscreen(cx), cfg!(target_os = "macos"), host::now()); + self.host_window_op(cx, op); + // A closed window, or a failed app, has no script left to tell. + if !matches!(ending, Ending::Closed | Ending::Failed) { + self.tell_app_fullscreen(cx, session.client, false); + } + log!("wm: app full screen ended: {} (client {}), {:?}", session.app, session.client, ending); + // The bar and the dock come back with it. + if self.state.is_some() { + self.sync_bar_for_fullscreen(cx); + self.update_bar(cx); + } + self.redraw_all(cx); + } + + /// A window reported its geometry (`WindowGeomChange`). Only the shell's + /// own window's full screen counts here, not an app's extra window's. + pub(crate) fn host_window_reported(&mut self, cx: &mut Cx, window: WindowId, fullscreen: bool) { + if self.ui.window(cx, ids!(main_window)).window_id() != Some(window) { + return; + } + let (op, left) = self.presenter.host.reported(fullscreen, host::now()); + self.host_window_op(cx, op); + if left { + self.end_presentation(cx, Ending::HostLeft); + } + } + + fn host_window_op(&mut self, cx: &mut Cx, op: Option) { + let window = self.ui.window(cx, ids!(main_window)); + // TODO(makepad): `Window::fullscreen` and `normal` push + // `FullscreenWindow` and `NormalizeWindow`, which makepad's macOS + // backend ignores; `maximize` and `restore` both toggle + // `toggleFullScreen:` there. Once makepad makes `fullscreen` and + // `normal` real on macOS, enter and leave through them. + match op { + // The bar's own window control makes the same two calls. + Some(HostOp::Enter) => window.maximize(cx), + Some(HostOp::Leave) => { + log!("wm: app full screen: the host window leaves the full screen it entered for it"); + window.restore(cx); + } + None => {} + } + } + + /// The app's optional `on_app_fullscreen(on)` hook, in its own isolate. + fn tell_app_fullscreen(&mut self, cx: &mut Cx, client: ClientId, on: bool) { + let told = self.module_host.dispatch(cx, client, "its full-screen hook", |cx, root| { + root.splash(cx, ids!(card)).call_script_fn(cx, id!(on_app_fullscreen), &[on.into()]) + }); + if told == Some(true) { + cx.with_vm_and_async(|_| {}); + } + } + + /// After every event: a presentation that no longer holds ends (its + /// window closed or holds another instance, its app failed, a phone + /// style came up, ⌘F took it out of full screen, or another window or + /// workspace took the focus). + pub(crate) fn sync_presentation(&mut self, cx: &mut Cx) { + let (Some(state), Some(session)) = (self.state.as_ref(), self.presenter.session.as_ref()) else { + return; + }; + let client = session.client; + let instance = self.module_host.get(client); + let alive = state.clients.contains_key(&client) && instance.is_some_and(|i| i.vm_id == session.instance); + let failed = instance.is_some_and(|i| i.failure().is_some()); + let layout = &state.layout; + let ending = ending_for( + alive, + failed, + state.style.target.mobile(), + layout.is_fullscreen(client), + layout.focused_client() == Some(client), + ); + if let Some(ending) = ending { + self.end_presentation(cx, ending); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn facts(window: Option) -> Facts { + Facts { window, in_front: true, pressed: true, ..Facts::default() } + } + + fn presentation(client: ClientId) -> Presentation { + Presentation { client, app: "os.quickdeck".into(), isolate: 100, instance: SplashVmId(1), was_covering: false } + } + + #[test] + fn only_on_true_or_false_is_a_request() { + assert_eq!(parse_on(&json!({"on": true})), Ok(true)); + assert_eq!(parse_on(&json!({"on": false})), Ok(false)); + for bad in [json!(null), json!(true), json!({}), json!({"on": 1}), json!({"on": "yes"}), json!({"on": true, "window": 3}), json!([true])] { + assert!(parse_on(&bad).unwrap_err().starts_with("invalid_arguments:"), "{bad}"); + } + } + + /// A host sheet is refused before the shell's turn, and so is any other + /// method of the family. The request's authority is weighed in the + /// shell's turn, with the window ([`decide`]). + #[test] + fn only_an_apps_own_surface_reaches_the_shell() { + let on = json!({"on": true}); + assert_eq!(admit("window.fullscreen", false, &on), Ok(true)); + assert_eq!(admit("window.fullscreen", false, &json!({"on": false})), Ok(false)); + assert_eq!(admit("window.fullscreen", true, &on), Err(NOT_A_WINDOW.into())); + assert!(admit("window.move", false, &on).unwrap_err().starts_with("method_unavailable:")); + assert!(admit("window.fullscreen", false, &json!({})).unwrap_err().starts_with("invalid_arguments:")); + } + + /// An app finds only its own window: the one whose Card runner runs the + /// isolate that asked. Another app's window, a glance card's isolate and + /// a window whose runner has no isolate are no one's to change. + #[test] + fn an_app_reaches_only_its_own_window() { + let windows = [ + WindowIdentity { client: 1, app: Some("os.quickdeck".into()), isolate: Some(100) }, + WindowIdentity { client: 2, app: Some("os.pdftools".into()), isolate: Some(200) }, + WindowIdentity { client: 3, app: Some("os.pdftools".into()), isolate: None }, + WindowIdentity { client: 4, app: None, isolate: Some(400) }, + ]; + assert_eq!(window_for("os.quickdeck", 100, &windows), Some(1)); + assert_eq!(window_for("os.pdftools", 200, &windows), Some(2)); + assert_eq!(window_for("os.quickdeck", 200, &windows), None, "another app's isolate"); + assert_eq!(window_for("os.pdftools", 100, &windows), None, "another app's window"); + assert_eq!(window_for("os.quickdeck", 999, &windows), None, "a glance card's isolate"); + assert_eq!(window_for("os.native", 400, &windows), None, "not a script app's window"); + assert_eq!(decide(true, &facts(None)), Decision::Refuse(NOT_A_WINDOW)); + assert_eq!(decide(false, &facts(None)), Decision::Refuse(NOT_A_WINDOW)); + } + + /// On: the app's window in front, pressed a moment ago, asked by the app + /// itself. Without the press, out of front, or from an agent's tool + /// call, it is refused, saying which. + #[test] + fn going_full_screen_needs_the_window_in_front_and_a_press() { + assert_eq!(decide(true, &facts(Some(7))), Decision::Begin(7)); + let unpressed = Facts { pressed: false, ..facts(Some(7)) }; + assert_eq!(decide(true, &unpressed), Decision::Refuse(GESTURE_REQUIRED)); + let behind = Facts { in_front: false, ..facts(Some(7)) }; + assert_eq!(decide(true, &behind), Decision::Refuse(FOREGROUND_REQUIRED)); + let agent = Facts { agent_call: true, ..facts(Some(7)) }; + assert_eq!(decide(true, &agent), Decision::Refuse(AGENT_CALL), "a press does not make an agent's call the person's"); + let agent_behind = Facts { agent_call: true, in_front: false, ..facts(Some(7)) }; + assert_eq!(decide(true, &agent_behind), Decision::Refuse(FOREGROUND_REQUIRED), "the window first"); + let presenting = Facts { presenting: Some(7), covering: true, pressed: false, ..facts(Some(7)) }; + assert_eq!(decide(true, &presenting), Decision::Answer(answer(false, true, true)), "already: nothing to apply"); + // Another window presenting does not stand in the way: its focus left. + let other = Facts { presenting: Some(8), ..facts(Some(7)) }; + assert_eq!(decide(true, &other), Decision::Begin(7)); + } + + /// Off: ends this window's presentation; otherwise there is nothing to + /// apply, and the answer says whether the window still covers the desk + /// (the person's ⌘F, which the app leaves alone). No press is needed, + /// nor the window in front, nor the surface's authority: an app may + /// always give the screen back. + #[test] + fn going_back_ends_only_the_apps_own_presentation() { + let presenting = Facts { presenting: Some(7), covering: true, pressed: false, in_front: false, ..facts(Some(7)) }; + assert_eq!(decide(false, &presenting), Decision::End(7)); + let from_agent = Facts { agent_call: true, ..presenting.clone() }; + assert_eq!(decide(false, &from_agent), Decision::End(7)); + assert_eq!(decide(false, &facts(Some(7))), Decision::Answer(answer(false, false, false))); + let by_person = Facts { covering: true, ..facts(Some(7)) }; + assert_eq!(decide(false, &by_person), Decision::Answer(answer(false, true, true))); + let other = Facts { presenting: Some(8), ..facts(Some(7)) }; + assert_eq!(decide(false, &other), Decision::Answer(answer(false, false, false)), "never another window's"); + } + + /// A phone's every app fills the screen and there is no immersive mode: + /// nothing to apply, and the answer says the window fills the screen. + #[test] + fn on_a_phone_the_request_changes_nothing() { + for on in [true, false] { + let phone = Facts { mobile: true, ..Facts::default() }; + assert_eq!(decide(on, &phone), Decision::Answer(answer(false, false, true))); + } + assert_eq!(answer(true, true, true), json!({"applied": true, "fullscreen": true, "fills_screen": true})); + } + + /// The surface's authority is no capability: App Hub gives an app's + /// window prompt authority whatever its manifest declares, and only a + /// tool call or a suspended app takes it away. An App Hub that tied it to + /// the `prompt` capability would have every app that does not declare + /// `prompt` refused `agent_call` (rule 12): this fails first. + #[cfg(feature = "app-hub")] + #[test] + fn no_capability_decides_a_requests_authority() { + // None at all, PDF Tools' and Quick Deck's (neither declares + // `prompt`), and `prompt` itself. + let declared = [json!([]), json!(["storage", "files", "pdf"]), json!(["storage", "files", "deck", "model"]), json!(["prompt"])]; + for capabilities in declared { + let manifest = octosense_app_contract::AppManifest::parse( + &json!({ + "schema": 1, "id": "org.example.presenter", "name": "Presenter", "version": "1.0.0", + "capabilities": capabilities, "integrity": {"bundle_blake3": ""} + }) + .to_string(), + ) + .unwrap(); + let limits = octosense_app_contract::HostLimits::default().with_require_signature(false); + let policy = octosense_app_contract::resolve(&manifest, &limits).unwrap(); + let settings = octosense_app_policy::IsolateSettings::for_app(&policy, std::path::Path::new("/data")); + assert!(settings.host_prompts, "declaring {capabilities} decided the window's authority"); + } + } + + /// A press counts for a few seconds, only in its own window, and once. + #[test] + fn a_press_activates_its_window_briefly_and_once() { + let mut a = Activation::default(); + assert!(!a.fresh(1, 10.0), "no press yet"); + a.press(1, 10.0); + assert!(a.fresh(1, 10.2)); + assert!(a.fresh(1, 10.0 + ACTIVATION_SECONDS)); + assert!(!a.fresh(1, 10.1 + ACTIVATION_SECONDS), "too long ago"); + assert!(!a.fresh(1, 9.0), "a clock that went back proves nothing"); + assert!(!a.fresh(2, 10.2), "another window's press"); + a.clear(1); + assert!(!a.fresh(1, 10.3), "used up"); + a.press(1, 11.0); + assert!(a.fresh(1, 11.1)); + // A later press anywhere drops the ones too old to count. + a.press(2, 30.0); + assert_eq!(a.presses.len(), 1); + } + + /// The presses through a presentation, as the shell wires them: the + /// press that begins it is used up, and one made while it presents (a + /// click that turned a slide) does not bring it back when the person + /// ends it, by Esc or any other way, and the app asks again, say from + /// `on_app_fullscreen(false)`. Only the app's own `{on: false}` leaves a + /// press it was given, as a browser's exit does. + #[test] + fn a_press_made_while_presenting_does_not_bring_it_back() { + let ask = |p: &Presenter, now: f64| decide(true, &Facts { pressed: p.pressed(7, now), ..facts(Some(7)) }); + for ending in [Ending::Escape, Ending::Uncovered, Ending::FocusLeft, Ending::HostLeft, Ending::Style, Ending::Failed, Ending::Closed] { + let mut p = Presenter::default(); + p.press(7, 1.0); + assert_eq!(ask(&p, 1.2), Decision::Begin(7)); + p.start(presentation(7)); + assert!(!p.pressed(7, 1.3), "the press that began it is used up"); + p.press(7, 2.0); + assert_eq!(p.finish(ending).map(|s| s.client), Some(7)); + assert_eq!(ask(&p, 2.5), Decision::Refuse(GESTURE_REQUIRED), "{ending:?} then asked again"); + p.press(7, 3.0); + assert_eq!(ask(&p, 3.1), Decision::Begin(7), "a new press after {ending:?} does"); + } + let mut p = Presenter::default(); + p.press(7, 1.0); + p.start(presentation(7)); + p.press(7, 2.0); + assert_eq!(p.finish(Ending::App).map(|s| s.client), Some(7)); + assert_eq!(ask(&p, 2.5), Decision::Begin(7), "the app's own toggle keeps the press that toggled it"); + assert!(p.finish(Ending::Escape).is_none(), "nothing presents"); + } + + /// Already full screen: the host window is left as it is at both ends. + #[test] + fn a_host_window_already_full_screen_stays_so() { + let mut host = HostFullscreen::default(); + assert_eq!(host.begin(true, true, 0.0), None); + assert_eq!(host.end(true, true, 1.0), None); + assert_eq!(host.end(true, false, 1.0), None); + } + + /// Not full screen: the presentation enters it and leaves it again, on a + /// toggling platform only while it reports full screen. + #[test] + fn a_presentation_leaves_only_the_full_screen_it_entered() { + for toggles in [true, false] { + let mut host = HostFullscreen::default(); + assert_eq!(host.begin(false, true, 0.0), Some(HostOp::Enter)); + assert_eq!(host.reported(true, 0.5), (None, false), "it went in"); + assert_eq!(host.reported(true, 1.0), (None, false), "another geometry change"); + assert_eq!(host.end(true, toggles, 5.0), Some(HostOp::Leave), "toggles {toggles}"); + assert_eq!(host.reported(false, 5.5), (None, false), "our own leave ends nothing"); + assert_eq!(host.end(false, toggles, 6.0), None, "nothing more to give back"); + } + // The person left it already (macOS): a toggle now would enter it again. + let mut host = HostFullscreen::default(); + host.begin(false, true, 0.0); + host.reported(true, 0.5); + assert_eq!(host.end(false, true, 5.0), None); + } + + /// The person leaving the host's full screen ends the presentation, which + /// then has no full screen to give back. + #[test] + fn the_person_leaving_the_host_full_screen_ends_the_presentation() { + for already in [false, true] { + let mut host = HostFullscreen::default(); + host.begin(already, true, 0.0); + host.reported(true, 0.5); + assert_eq!(host.reported(false, 1.0), (None, true), "already {already}"); + assert_eq!(host.end(false, true, 2.0), None); + } + } + + /// Ended while the host window is still on its way in: on a toggling + /// platform the window's next report decides, however late it comes + /// within [`PENDING_LEAVE_SECONDS`]: in, and it leaves; not in (a failed + /// enter), and nothing is given back. Elsewhere it leaves at once. + #[test] + fn a_presentation_ended_while_entering_leaves_once_in_however_late() { + let mut host = HostFullscreen::default(); + host.begin(false, true, 0.0); + assert_eq!(host.end(false, true, 0.5), None, "a toggle now would turn it round"); + let late = 0.5 + PENDING_LEAVE_SECONDS - 1.0; + assert_eq!(host.reported(true, late), (Some(HostOp::Leave), false), "in, long after: it goes back out"); + assert_eq!(host.reported(false, late + 0.5), (None, false), "our own leave ends nothing"); + assert_eq!(host.reported(true, late + 9.0), (None, false), "and the next full screen is the person's"); + + let mut host = HostFullscreen::default(); + host.begin(false, true, 0.0); + host.end(false, true, 0.5); + assert_eq!(host.reported(false, 1.0), (None, false), "the enter failed"); + assert_eq!(host.reported(true, 9.0), (None, false), "so a full screen after it is the person's"); + assert_eq!(host.begin(false, true, 10.0), Some(HostOp::Enter), "and a new presentation asks again"); + + let mut host = HostFullscreen::default(); + host.begin(false, true, 0.0); + assert_eq!(host.end(false, false, 0.5), Some(HostOp::Leave), "maximize and restore do not toggle"); + } + + /// A leave that waits has a limit. A presentation began while the window + /// was still leaving the last full screen, so macOS ignored its enter, + /// and the window's report of that leave came during the presentation. + /// Once [`PENDING_LEAVE_SECONDS`] pass after the presentation ends, the + /// person's own full screen is theirs, and a new presentation asks to + /// enter again instead of waiting for an enter that never came. + #[test] + fn a_leave_waiting_for_an_enter_that_never_came_expires() { + let mut host = HostFullscreen::default(); + assert_eq!(host.begin(false, true, 0.0), Some(HostOp::Enter), "macOS ignores it mid-transition"); + assert_eq!(host.reported(false, 0.8), (None, false), "the old leave's report, while it presents"); + assert_eq!(host.end(false, true, 30.0), None, "still waiting for its enter"); + let later = 30.0 + PENDING_LEAVE_SECONDS + 1.0; + assert_eq!(host.reported(true, later), (None, false), "the person's own full screen stays"); + assert_eq!(host.end(true, true, later + 1.0), None, "and nothing of a presentation's is left"); + + let mut host = HostFullscreen::default(); + host.begin(false, true, 0.0); + host.end(false, true, 1.0); + assert_eq!(host.begin(false, true, 1.0 + PENDING_LEAVE_SECONDS + 1.0), Some(HostOp::Enter), "it asks again"); + assert_eq!(host.reported(true, 30.0), (None, false), "in, for the new presentation"); + assert_eq!(host.end(true, true, 40.0), Some(HostOp::Leave)); + } + + /// A presentation beginning again while the enter asked for before is on + /// its way does not toggle twice, and keeps that enter. + #[test] + fn a_new_presentation_keeps_an_enter_on_its_way() { + let mut host = HostFullscreen::default(); + assert_eq!(host.begin(false, true, 0.0), Some(HostOp::Enter)); + assert_eq!(host.end(false, true, 0.2), None); + assert_eq!(host.begin(false, true, 0.4), None, "the first enter serves it"); + assert_eq!(host.reported(true, 0.6), (None, false), "in, for the new presentation"); + assert_eq!(host.end(true, true, 3.0), Some(HostOp::Leave)); + } + + /// Hidden windows: the host window is never asked, so nothing is given back. + #[test] + fn hidden_windows_are_never_asked_to_go_full_screen() { + let mut host = HostFullscreen::default(); + assert_eq!(host.begin(false, false, 0.0), None); + assert_eq!(host.reported(true, 0.5), (None, false)); + assert_eq!(host.end(false, true, 1.0), None); + } + + /// The presentation holds while its window is open with the instance + /// that began it, its app runs, a desktop style is up, it covers the + /// desk and has the focus; each of those ends it. + #[test] + fn a_presentation_ends_when_its_window_closes_fails_or_lets_go() { + assert_eq!(ending_for(true, false, false, true, true), None); + assert_eq!(ending_for(false, false, false, true, true), Some(Ending::Closed), "closed, or another instance"); + assert_eq!(ending_for(false, true, true, false, false), Some(Ending::Closed)); + assert_eq!(ending_for(true, true, false, true, true), Some(Ending::Failed), "its module panicked"); + assert_eq!(ending_for(true, false, true, true, true), Some(Ending::Style)); + assert_eq!(ending_for(true, false, false, false, true), Some(Ending::Uncovered), "⌘F"); + assert_eq!(ending_for(true, false, false, true, false), Some(Ending::FocusLeft)); + } + + /// Esc ends a presentation; ⌃Esc stays the launcher's, and with none on, + /// Esc is the app's as before. + #[test] + fn esc_is_the_shells_while_a_window_presents() { + let plain = KeyModifiers::default(); + assert!(esc_ends(KeyCode::Escape, &plain, true)); + assert!(!esc_ends(KeyCode::Escape, &plain, false)); + assert!(!esc_ends(KeyCode::Escape, &KeyModifiers { control: true, ..plain }, true)); + assert!(!esc_ends(KeyCode::Escape, &KeyModifiers { logo: true, ..plain }, true)); + assert!(!esc_ends(KeyCode::KeyF, &plain, true)); + } + + /// Requests that changed nothing write one line per app every few + /// seconds, and the next line says how many went unwritten. + #[test] + fn a_flood_of_refusals_writes_a_line_now_and_then() { + let mut log = QuietLog::default(); + assert_eq!(log.note("os.flood", 0.0), Some(0)); + for i in 1..=50 { + assert_eq!(log.note("os.flood", i as f64 * 0.1), None); + } + assert_eq!(log.note("os.other", 1.0), Some(0), "every app has its own line"); + assert_eq!(log.note("os.flood", QUIET_LOG_SECONDS + 0.1), Some(50)); + assert_eq!(log.note("os.flood", QUIET_LOG_SECONDS + 0.2), None); + } +} diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index edf71ad9..034eba4a 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -292,6 +292,9 @@ pub fn register_host_services() { }); crate::files_service::register(); crate::audio_service::register(); + // `window.fullscreen`: an app's own window in presentation full + // screen, from the person's tap (app_fullscreen.rs). + crate::app_fullscreen::register(); // App Hub admits bundles before the first CardModule is opened. The // linked card runner already supports app tools at that point, so its // ABI must not depend on a card's lazy VM registration. diff --git a/crates/shell/src/desk.rs b/crates/shell/src/desk.rs index 6294edeb..8b7ea766 100644 --- a/crates/shell/src/desk.rs +++ b/crates/shell/src/desk.rs @@ -418,6 +418,13 @@ pub struct WmState { /// The height of the bar sliding out over a full-screen window, above /// its title bar: 0 when no bar comes out with it (`App::place_bar`). pub top_bar: f64, + /// The window an app's presentation full screen covers + /// (`window.fullscreen`, app_fullscreen.rs), set and cleared with the + /// presentation. While it is set no edge brings anything out: the top + /// bar and the window's title bar stay above the top edge and the dock, + /// taskbar or column stays away, as in a slideshow's play mode. Esc and + /// the app's own exit are the ways out. + pub presentation: Option, } impl WmState { diff --git a/crates/shell/src/desktop.rs b/crates/shell/src/desktop.rs index 9a5532d9..d2569128 100644 --- a/crates/shell/src/desktop.rs +++ b/crates/shell/src/desktop.rs @@ -633,6 +633,18 @@ mod tests { assert_eq!(dock_slide(dock, bottom, 2.0), 0.0); } + /// While an app presents, no dock, taskbar or column shows, whatever the + /// edge reveal says; otherwise a dock shows as before. + #[test] + fn an_apps_presentation_keeps_the_dock_away() { + for (auto_hides, reveal) in [(false, 1.0), (false, 0.0), (true, 1.0), (true, 0.5), (true, 0.0)] { + assert!(!dock_shows(true, auto_hides, reveal), "presenting, auto_hides {auto_hides}, reveal {reveal}"); + } + assert!(dock_shows(false, false, 0.0), "a dock that does not hide"); + assert!(dock_shows(false, true, 0.5), "partway up"); + assert!(!dock_shows(false, true, 0.0), "all the way down"); + } + #[test] fn the_top_edge_calls_a_full_screen_windows_top_and_leaving_it_sends_it_back() { let screen = rect(0.0, 0.0, 1440.0, 900.0); @@ -1235,10 +1247,16 @@ pub fn top_pointer_wants(p: Vec2d, screen: Rect, depth: f64, out: bool) -> bool let over = out && p.y <= screen.pos.y + depth + 8.0; inside && (on_edge || over) } -/// Whether any of the dock is on screen this frame: one that does not hide, -/// or one not all the way down. +/// Whether any of the dock is on screen this frame (`dock_shows`). pub fn dock_showing(state: &WmState) -> bool { - !dock_auto_hides(state) || state.dock_reveal > 0.001 + dock_shows(state.presentation.is_some(), dock_auto_hides(state), state.dock_reveal) +} +/// Whether any of a dock is on screen: none while an app presents +/// (`presenting`: `WmState::presentation`), whatever the style and whatever +/// the bottom edge says, as a slideshow's play mode keeps the Dock away; else +/// one that does not hide (`auto_hides`), or one not all the way down. +pub fn dock_shows(presenting: bool, auto_hides: bool, reveal: f64) -> bool { + !presenting && (!auto_hides || reveal > 0.001) } /// How far below its place a dock `reveal` of the way up is drawn: just past /// the bottom edge at 0, in place at 1, eased between. diff --git a/crates/shell/src/desktop_app.rs b/crates/shell/src/desktop_app.rs index 32226fad..d292aef6 100644 --- a/crates/shell/src/desktop_app.rs +++ b/crates/shell/src/desktop_app.rs @@ -507,10 +507,13 @@ impl App { let menu = self.ui.widget(cx, ids!(shell_menu)).borrow::().map(|m| (m.model.open, m.model.skin)); let held = matches!(menu, Some((true, MenuSkin::Launcher))); let bar_menu = matches!(menu, Some((true, MenuSkin::Menu))); + // While an app presents (`WmState::presentation`), no edge brings + // anything out: Esc and the app's own exit are the ways out. + let presenting = self.state.as_ref().is_some_and(|s| s.presentation.is_some()); let shelf = self.ui.widget(cx, ids!(desktop_shelf)); let (hit, inside) = if let Some(mut s) = shelf.borrow_mut::() { s.hover_at(cx, p); - s.auto_hide_pointer(cx, Some(p), held); + s.auto_hide_pointer(cx, (!presenting).then_some(p), held && !presenting); (s.hit(p), s.contains(p)) } else { (None, false) @@ -522,7 +525,7 @@ impl App { // The bar coming out with it, then the title bar. let depth = self.state_mut().top_bar + self.state_mut().style.target.title_height(); let wants = bar_menu || crate::desktop::top_pointer_wants(p, screen, depth, self.top_edge.out); - self.top_edge.want(cx, wants); + self.top_edge.want(cx, wants && !presenting); } let chrome_hit = self.desk(cx).borrow_mut::() .and_then(|mut d| d.chrome_pointer(cx, (!inside).then_some(p))); diff --git a/crates/shell/src/launch_input.rs b/crates/shell/src/launch_input.rs index 00936065..5afa001b 100644 --- a/crates/shell/src/launch_input.rs +++ b/crates/shell/src/launch_input.rs @@ -89,6 +89,15 @@ pub fn flag(key: &str) -> bool { app_config().and_then(|config| config.get(key).and_then(|value| value.as_bool())).unwrap_or(false) } +/// Makepad's hidden-window mode (`MAKEPAD_HIDE_WINDOWS`, which test and +/// evaluation runs set): the platform never orders a window onto the screen. +/// The shell then never asks the OS to put its window in full screen, which +/// on macOS could bring the hidden window onto the screen in a Space of its +/// own (app_fullscreen.rs). +pub fn hidden_windows() -> bool { + std::env::var_os("MAKEPAD_HIDE_WINDOWS").is_some() +} + fn note_ignored() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { diff --git a/crates/shell/src/layout.rs b/crates/shell/src/layout.rs index f0768474..d8a73897 100644 --- a/crates/shell/src/layout.rs +++ b/crates/shell/src/layout.rs @@ -1523,6 +1523,67 @@ impl WmLayout { self.workspaces.iter().any(|w| w.fullscreen == Some(client) && w.fullscreen_mode == FullscreenMode::Fullscreen) } + /// Put `client` in full screen or take it out, set rather than toggled: + /// an app's presentation (`window.fullscreen`, app_fullscreen.rs) names + /// its own window, where SUPER+F toggles the focused one. On a floating + /// desk it is the window's `DesktopWindow::fullscreen` (a minimized window + /// is shown again; its rect, its zoom and its place in the stack stay as + /// they are); on a tiling desk it is the workspace's SUPER+F fullscreen, + /// which a tiled float does not take. False when nothing changed. + pub fn set_fullscreen(&mut self, client: ClientId, on: bool) -> bool { + if self.desktop.enabled { + let Some(w) = self.desktop.get_mut(client) else { + return false; + }; + if w.fullscreen == on { + return false; + } + w.fullscreen = on; + if on { + w.minimized = false; + } + return true; + } + let Some(ws) = self.workspace_of(client) else { + return false; + }; + if on && self.is_float(client) { + return false; + } + let w = &mut self.workspaces[ws]; + let full = w.fullscreen == Some(client) && w.fullscreen_mode == FullscreenMode::Fullscreen; + if full == on { + return false; + } + if on { + w.fullscreen = Some(client); + w.fullscreen_mode = FullscreenMode::Fullscreen; + } else { + w.fullscreen = None; + w.fullscreen_mode = FullscreenMode::None; + } + true + } + + /// Take `client` out of full screen on both desks, the floating one and + /// the tiling one, whichever is up: a presentation ends this way even + /// after a style switch. True when anything changed. + pub fn clear_fullscreen(&mut self, client: ClientId) -> bool { + let mut changed = false; + if let Some(w) = self.desktop.get_mut(client).filter(|w| w.fullscreen) { + w.fullscreen = false; + changed = true; + } + for w in &mut self.workspaces { + if w.fullscreen == Some(client) && w.fullscreen_mode == FullscreenMode::Fullscreen { + w.fullscreen = None; + w.fullscreen_mode = FullscreenMode::None; + changed = true; + } + } + changed + } + /// SUPER+CTRL+F — `fullscreenstate 0 2`: the client is TOLD it is /// fullscreen, the layout does not change. pub fn toggle_client_fullscreen(&mut self, client: ClientId) -> bool { @@ -2039,6 +2100,64 @@ mod tests { assert!(l.is_fullscreen(2) && !l.is_fullscreen(1)); } + /// An app's presentation sets its own window's full screen rather than + /// toggling the focused one's, on a floating desk and a tiling one, and + /// a closed window leaves none behind. + #[test] + fn set_fullscreen_sets_a_named_window_and_a_closed_window_ends_it() { + let mut l = WmLayout::new(); + l.desktop.enabled = true; + let desk = LRect::new(-10.0, -10.0, 1020.0, 620.0); + l.set_outer(desk); + for client in 1..=3 { + l.insert(client, AREA, 10.0); + } + let before = l.rects(AREA, 10.0); + l.desktop.get_mut(1).unwrap().minimized = true; + assert!(l.set_fullscreen(1, true), "not the focused window (3)"); + assert!(!l.set_fullscreen(1, true), "already"); + assert!(!l.desktop.get(1).unwrap().minimized, "shown again"); + assert!(l.is_fullscreen(1) && !l.is_fullscreen(3)); + assert_eq!(l.rect_of(1, AREA, 10.0), Some(desk)); + assert_eq!(l.focused_client(), Some(3), "focus is the caller's business"); + assert!(l.set_fullscreen(1, false)); + assert!(!l.set_fullscreen(1, false)); + assert_eq!(l.rects(AREA, 10.0), before, "everything back as it was"); + assert!(l.set_fullscreen(2, true)); + l.remove(2); + assert!(!l.is_fullscreen(2)); + assert!(!l.set_fullscreen(2, true), "no such window"); + + // Tiling: the workspace's SUPER+F fullscreen, which Full width + // (maximized) gives way to; a float takes none. + let mut l = abc(); + l.toggle_fullscreen_mode(FullscreenMode::Maximized); + assert!(l.set_fullscreen(2, true)); + assert_eq!(l.rects(AREA, 8.0), vec![(2, LRect::new(-8.0, -8.0, 1016.0, 616.0))]); + assert!(l.is_fullscreen(2) && !l.is_fullscreen(3)); + assert!(l.set_fullscreen(2, false)); + assert_eq!(l.rects(AREA, 0.0).len(), 3); + l.toggle_float(3, AREA, 0.0); + assert!(!l.set_fullscreen(3, true)); + } + + /// A presentation ends in whichever style is up now: the window's + /// floating full screen and its workspace's tiled one both go. + #[test] + fn clear_fullscreen_clears_both_desks() { + let mut l = abc(); + l.desktop.enabled = true; + assert!(l.set_fullscreen(3, true)); + l.desktop.enabled = false; + assert!(!l.is_fullscreen(3), "tiling, a floating full screen covers nothing"); + assert!(l.set_fullscreen(3, true)); + assert!(l.clear_fullscreen(3)); + assert!(!l.desktop.get(3).unwrap().fullscreen && !l.is_fullscreen(3)); + l.desktop.enabled = true; + assert!(!l.is_fullscreen(3)); + assert!(!l.clear_fullscreen(3), "nothing left to clear"); + } + fn rect_of(l: &WmLayout, c: ClientId) -> LRect { rect_gap(l, c, 0.0) } diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index e3b0296f..655b4c0a 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -18,6 +18,7 @@ pub mod mail_background; pub mod agent_events; pub mod ai_bus; pub mod app_chat; +pub mod app_fullscreen; pub mod app_storage; pub mod dev_mode; pub mod launch_input; @@ -138,6 +139,7 @@ mod mail_compose; use glance::NoteTargets as GlanceNoteTargets; // The App derive takes a plain type name for a field. use approvals::RequestNotices as ApprovalNotices; +use app_fullscreen::Presenter as FullscreenPresenter; pub mod system_chat; pub use octosense_ai_host as ai_host; @@ -587,6 +589,11 @@ pub struct App { /// SUPER+SHIFT+SPACE — so it comes back on its own. #[rust] pub bar_hidden_by_fullscreen: bool, + /// An app's presentation full screen (`window.fullscreen`), the presses + /// that allow one, and the host window's full screen around it + /// (app_fullscreen.rs). + #[rust] + pub presenter: FullscreenPresenter, /// Output lines from every child, for the tile's status line. #[rust] pub client_lines: Option, @@ -6166,6 +6173,7 @@ mod app_hub_lifecycle_tests { dock_reveal: 1.0, top_reveal: 0.0, top_bar: 0.0, + presentation: None, }); // Real module isolates exercise shutdown and client removal without // requiring a downloaded Card or changing the machine's catalog. @@ -6314,6 +6322,7 @@ impl MatchEvent for App { dock_reveal: 1.0, top_reveal: 0.0, top_bar: 0.0, + presentation: None, style: Default::default(), material, roles, @@ -6614,9 +6623,10 @@ impl MatchEvent for App { } } } - MpRunViewAction::Clicked { client } => { - self.focus_client(cx, client); - } + MpRunViewAction::Clicked { client } => self.focus_client(cx, client), + // A press on the app's own content in this window: for a few + // seconds the app may take it full screen (app_fullscreen.rs). + MpRunViewAction::Pressed { client } => self.note_window_press(client), MpRunViewAction::Restart { client } => self.restart_failed_module(cx, client), MpRunViewAction::None => {} } @@ -6777,6 +6787,14 @@ impl App { // or draw, or in a call the shell made — is contained by now; show // it closed and free it before the next event (module_host.rs). self.contain_module_faults(cx); + // The apps' `window.fullscreen` requests this event pumped, then + // whether a presentation still holds (app_fullscreen.rs). Not in a + // draw: an app's hook may redraw. + if !matches!(event, Event::Draw(_)) { + #[cfg(any(feature = "app-hub", native_mobile))] + self.serve_window_requests(cx); + self.sync_presentation(cx); + } // Modules' peer links (#142): the links opened during this event, // and what the instances sent on theirs, to the shell's peer link. self.module_host.pump_peer_links(cx); @@ -6912,6 +6930,11 @@ impl App { self.state_mut().host_fullscreen = fullscreen; self.redraw_all(cx); } + // An app's presentation follows the window's real full screen + // (app_fullscreen.rs): one it asked for arrived, or the person left. + if self.state.is_some() { + self.host_window_reported(cx, ev.window_id, ev.new_geom.is_fullscreen); + } // A warm instance is configured with the desktop's own dpi, so // what it warms up is what its tile will use. self.dpi_factor = ev.new_geom.dpi_factor; @@ -7143,6 +7166,14 @@ impl App { self.system_chat_changed(cx); return; } + // Esc ends an app's presentation full screen: the shell's key + // while one is on, since a script app gets no keys + // (app_fullscreen.rs). The surfaces above keep theirs first. + if app_fullscreen::esc_ends(e.key_code, &e.modifiers, self.presenter.session.is_some()) { + self.alt_armed = false; + self.end_presentation(cx, app_fullscreen::Ending::Escape); + return; + } // F8: the system chat, everywhere. if e.key_code == KeyCode::F8 && bare_key(&e.modifiers) { self.alt_armed = false; diff --git a/crates/shell/src/module_input_tests.rs b/crates/shell/src/module_input_tests.rs index 023feda6..2a0b0485 100644 --- a/crates/shell/src/module_input_tests.rs +++ b/crates/shell/src/module_input_tests.rs @@ -290,6 +290,9 @@ fn touch(abs: Vec2d, state: TouchState) -> Event { enum Asked { Raise(u64), Restart(u64), + /// A press on the app's own content, which lets the app take its window + /// full screen for a few seconds (app_fullscreen.rs). + Press(u64), } /// Offer each event to the tiles front to back, as the desk does, and @@ -308,6 +311,7 @@ fn offer(cx: &mut Cx, front_to_back: &[&WidgetRef], events: &[Event]) -> Vec() { MpRunViewAction::Clicked { client } => Some(Asked::Raise(client)), MpRunViewAction::Restart { client } => Some(Asked::Restart(client)), + MpRunViewAction::Pressed { client } => Some(Asked::Press(client)), _ => None, }) .collect() @@ -371,11 +375,11 @@ fn close(mut cx: Cx, mut host: ModuleHost, tiles: Vec, frame: Frame, fn a_click_on_the_window_in_front_never_reaches_the_app_behind() { let (mut cx, mut host, news, mail, frame) = news_and_mail(&NEWS); let over_both = dvec2(OVER_BOTH.0, OVER_BOTH.1); - assert_eq!(click(&mut cx, &[&mail, &news], over_both), [Asked::Raise(2)], "Mail alone is raised"); + assert_eq!(click(&mut cx, &[&mail, &news], over_both), [Asked::Raise(2), Asked::Press(2)], "Mail alone is raised, and pressed"); assert_eq!(counts(&mut cx, &mut host, 2), (1, 0), "Mail's app saw the press"); assert_eq!(counts(&mut cx, &mut host, 1), (0, 0), "News's app never saw it, so no story opened"); // Where News is in front of nothing, the same row takes the click. - assert_eq!(click(&mut cx, &[&mail, &news], dvec2(NEWS_ONLY.0, NEWS_ONLY.1)), [Asked::Raise(1)]); + assert_eq!(click(&mut cx, &[&mail, &news], dvec2(NEWS_ONLY.0, NEWS_ONLY.1)), [Asked::Raise(1), Asked::Press(1)]); assert_eq!(counts(&mut cx, &mut host, 1), (1, 1), "a click where News is in front opens its story"); assert_eq!(counts(&mut cx, &mut host, 2), (1, 0)); close(cx, host, vec![news, mail], frame, &[1, 2]); @@ -386,10 +390,10 @@ fn a_click_on_the_window_in_front_never_reaches_the_app_behind() { #[test] fn a_touch_on_the_window_in_front_never_reaches_the_app_behind() { let (mut cx, mut host, news, mail, frame) = news_and_mail(&NEWS); - assert_eq!(tap(&mut cx, &[&mail, &news], dvec2(OVER_BOTH.0, OVER_BOTH.1)), [Asked::Raise(2)]); + assert_eq!(tap(&mut cx, &[&mail, &news], dvec2(OVER_BOTH.0, OVER_BOTH.1)), [Asked::Raise(2), Asked::Press(2)]); assert_eq!(counts(&mut cx, &mut host, 2), (1, 0)); assert_eq!(counts(&mut cx, &mut host, 1), (0, 0), "News's app never saw the touch"); - assert_eq!(tap(&mut cx, &[&mail, &news], dvec2(NEWS_ONLY.0, NEWS_ONLY.1)), [Asked::Raise(1)]); + assert_eq!(tap(&mut cx, &[&mail, &news], dvec2(NEWS_ONLY.0, NEWS_ONLY.1)), [Asked::Raise(1), Asked::Press(1)]); assert_eq!(counts(&mut cx, &mut host, 1), (1, 1)); close(cx, host, vec![news, mail], frame, &[1, 2]); } @@ -401,11 +405,11 @@ fn a_gesture_view_behind_the_window_in_front_never_captures_its_press() { let (mut cx, mut host, news, mail, frame) = news_and_mail(&GESTURE_NEWS); let row = host.dispatch(&mut cx, 1, "a test read", |_, root| root.area()).unwrap(); let over_both = dvec2(OVER_BOTH.0, OVER_BOTH.1); - assert_eq!(offer(&mut cx, &[&mail, &news], &[mouse_down(over_both)]), [Asked::Raise(2)]); + assert_eq!(offer(&mut cx, &[&mail, &news], &[mouse_down(over_both)]), [Asked::Raise(2), Asked::Press(2)]); assert!(!cx.fingers.is_area_captured(row), "News's GestureView took a press made on Mail"); offer(&mut cx, &[&mail, &news], &[mouse_up(over_both)]); let news_only = dvec2(NEWS_ONLY.0, NEWS_ONLY.1); - assert_eq!(offer(&mut cx, &[&mail, &news], &[mouse_down(news_only)]), [Asked::Raise(1)]); + assert_eq!(offer(&mut cx, &[&mail, &news], &[mouse_down(news_only)]), [Asked::Raise(1), Asked::Press(1)]); assert!(cx.fingers.is_area_captured(row), "where News is in front its row takes the press"); offer(&mut cx, &[&mail, &news], &[mouse_up(news_only)]); close(cx, host, vec![news, mail], frame, &[1, 2]); @@ -493,13 +497,13 @@ fn a_stopped_tile_takes_only_a_press_nothing_in_front_took() { let restart = stopped.borrow::().unwrap().restart_button(&cx).expect("the closed face has its Restart"); assert!(restart.is_inside_of(mail_at) && under_at.contains(restart.center()), "Mail covers the Restart: {restart:?}"); let front_to_back = [&over, &stopped, &under]; - assert_eq!(click(&mut cx, &front_to_back, restart.center()), [Asked::Raise(2)], "a click on Mail is Mail's"); + assert_eq!(click(&mut cx, &front_to_back, restart.center()), [Asked::Raise(2), Asked::Press(2)], "a click on Mail is Mail's"); assert_eq!(counts(&mut cx, &mut host, 3), (0, 0)); // On the face, clear of Mail: the stopped tile is raised, and the app // under it never sees the press. let face = dvec2(480.0, 300.0); assert!(stopped_at.contains(face) && !mail_at.contains(face) && under_at.contains(face)); - assert_eq!(click(&mut cx, &front_to_back, face), [Asked::Raise(1)]); + assert_eq!(click(&mut cx, &front_to_back, face), [Asked::Raise(1)], "and no app content was pressed"); assert_eq!(counts(&mut cx, &mut host, 3), (0, 0)); // Mail moved off it: its Restart restarts it. drop(frame); @@ -539,3 +543,34 @@ fn the_restart_on_a_stopped_face_responds_where_it_is_drawn() { assert_eq!(tap(&mut cx, &[&stopped], top_left), [Asked::Raise(1)], "a tap at the face's top left restarted the app"); close(cx, host, vec![stopped], frame, &[1]); } + +/// A card app's window with App Hub's own runner: a press on the app counts +/// for its full screen (app_fullscreen.rs). One on a host service's sheet +/// over it (a sign-in, a wizard) is the host's: it raises the window and +/// counts for nothing, and once the sheet is gone the app's presses count +/// again. +#[cfg(feature = "app-hub")] +#[test] +fn a_press_on_a_host_sheet_over_a_card_app_is_not_the_apps() { + let (mut cx, mut host) = setup(); + host.apply_style(&mut cx, &desktop_style::StyleSheet::load(desktop_style::DesktopStyle::Macos)); + let module = &octosense_appstore::cardapp::CARD_MODULE; + let open = module.open_schema().validate(r#"{"app":"os.press-probe"}"#, &[]).unwrap(); + host.create(&mut cx, 1, module, open, dvec2(400.0, 500.0)).unwrap(); + let card = tile(&mut cx, &host, 1); + let frame = draw_stack(&mut cx, &[(&card, at(20.0, 40.0))]); + let inside = dvec2(220.0, 300.0); + let sheet = |cx: &mut Cx, host: &mut ModuleHost, up: bool| { + host.dispatch(cx, 1, "a test sheet", |cx, root| { + root.splash(cx, ids!(sheet)).borrow_mut().expect("the runner's sheet").view.visible = up; + }) + .unwrap(); + }; + assert_eq!(click(&mut cx, &[&card], inside), [Asked::Raise(1), Asked::Press(1)], "a press on the app"); + sheet(&mut cx, &mut host, true); + assert_eq!(click(&mut cx, &[&card], inside), [Asked::Raise(1)], "a press on the sheet"); + assert_eq!(tap(&mut cx, &[&card], inside), [Asked::Raise(1)], "a touch on the sheet"); + sheet(&mut cx, &mut host, false); + assert_eq!(click(&mut cx, &[&card], inside), [Asked::Raise(1), Asked::Press(1)], "the sheet gone"); + close(cx, host, vec![card], frame, &[1]); +} diff --git a/crates/shell/src/module_view.rs b/crates/shell/src/module_view.rs index cc3fe5ad..7294e6b9 100644 --- a/crates/shell/src/module_view.rs +++ b/crates/shell/src/module_view.rs @@ -489,6 +489,18 @@ impl Widget for MpModuleView { // The WM moves focus here (and back to us through // `focus_keyboard`), exactly as for a process tile. cx.widget_action(self.uid, MpRunViewAction::Clicked { client }); + // A press on the app's own content: for a few seconds the app + // may take its window full screen (app_fullscreen.rs). One on + // a host service's sheet over the app is the host's. Asked + // before the app sees the press, which may close the sheet. + match contain(cx, self.vm_id, "its sheet", |cx| crate::app_fullscreen::host_sheet_up(cx, &root)) { + Some(false) => cx.widget_action(self.uid, MpRunViewAction::Pressed { client }), + Some(true) => {} + None => { + self.stop(cx); + return; + } + } } } // A redraw an asleep app asks for (a timer, a network reply) is held diff --git a/crates/shell/src/run_view.rs b/crates/shell/src/run_view.rs index 5263d695..aa72ab1e 100644 --- a/crates/shell/src/run_view.rs +++ b/crates/shell/src/run_view.rs @@ -163,6 +163,13 @@ pub enum MpRunViewAction { Clicked { client: ClientId, }, + /// The person pressed the app's own content in this tile: a module's + /// live root with no host service's sheet up over it, never a group + /// tab, a stopped app's face or a process tile. For a few seconds the + /// app may take its window full screen (`app_fullscreen.rs`). + Pressed { + client: ClientId, + }, /// The person asked a tile whose app stopped (a module after a panic, /// `module_view.rs`; a process that died, [`MpRunView::show_stopped`]) /// to start it again. diff --git a/crates/shell/src/shell/osd.rs b/crates/shell/src/shell/osd.rs index d7e4554b..c07fefec 100644 --- a/crates/shell/src/shell/osd.rs +++ b/crates/shell/src/shell/osd.rs @@ -29,6 +29,12 @@ const CARD_ALPHA: f32 = 0.97; pub const DEFAULT_DURATION: f64 = 1.2; /// The fill's `Behavior on width`. const FILL_EASE: f64 = 0.14; +/// The widest a message line gets before it is elided (`Osd.qml`). +const MESSAGE_WIDTH: f64 = 190.0; +/// The full-screen hint's line, which must read whole. +const HINT_WIDTH: f64 = 360.0; +/// How long the full-screen hint stays up. +pub const FULLSCREEN_HINT_DURATION: f64 = 3.0; /// What the OSD is showing. #[derive(Clone, Debug, PartialEq)] @@ -39,6 +45,8 @@ pub struct OsdShow { /// The readout — omarchy defaults it to `percent + "%"`. pub message: String, pub duration: f64, + /// The widest a message-only OSD's line gets before it is elided. + pub line_width: f64, } impl OsdShow { @@ -52,6 +60,7 @@ impl OsdShow { format!("{}%", level) }, duration: DEFAULT_DURATION, + line_width: MESSAGE_WIDTH, } } @@ -61,6 +70,7 @@ impl OsdShow { value: Some(level as f64 / 100.0), message: format!("{}%", level), duration: DEFAULT_DURATION, + line_width: MESSAGE_WIDTH, } } @@ -74,6 +84,20 @@ impl OsdShow { "Microphone live".to_string() }, duration: DEFAULT_DURATION, + line_width: MESSAGE_WIDTH, + } + } + + /// The shell's word when an app's window goes full screen + /// (app_fullscreen.rs): how to leave, for a few seconds. Esc is the + /// shell's own key there, since a script app gets no keys. + pub fn fullscreen_hint() -> Self { + Self { + icon: Ico::Keyboard, + value: None, + message: "Press Esc to exit full screen".to_string(), + duration: FULLSCREEN_HINT_DURATION, + line_width: HINT_WIDTH, } } } @@ -161,7 +185,7 @@ impl ShellOsd { } else { self.d .measure(cx, true, tok.font.title, &show.message) - .min(190.0) + .min(show.line_width) }; let content = if show.value.is_some() { icon + gap + BAR_WIDTH + gap + text_w @@ -322,5 +346,17 @@ mod tests { let s = OsdShow::mic(true); assert!(s.value.is_none()); assert_eq!(s.icon, Ico::MicOff); + assert_eq!(s.line_width, MESSAGE_WIDTH); + } + + /// The full-screen hint says how to leave, briefly, on a line wide + /// enough to read whole. + #[test] + fn the_full_screen_hint_names_esc_and_goes_away() { + let s = OsdShow::fullscreen_hint(); + assert_eq!(s.message, "Press Esc to exit full screen"); + assert!(s.value.is_none()); + assert!(s.duration > DEFAULT_DURATION && s.duration <= 5.0); + assert!(s.line_width > MESSAGE_WIDTH); } } diff --git a/desktop/KEYBINDINGS.md b/desktop/KEYBINDINGS.md index 859b5e16..b270864e 100644 --- a/desktop/KEYBINDINGS.md +++ b/desktop/KEYBINDINGS.md @@ -91,6 +91,7 @@ drop it on), ⌘ + right-drag resizes it from the corner nearest your grab. |-----------|-------------------------------------------------------------------| | ⌘ **F** | Full screen (the window covers the desk, bar hidden; on a floating desk the bar, its title bar and the dock come out at the screen's edges, as the green button does) | | ⌘⌥ **F** | Full width (maximized inside the gaps, bar stays) | +| **Esc** | Leave an app's presentation full screen (`window.fullscreen`), where no edge brings the bar or the dock out | | ⌘⌃ **F** | Tiled full screen (the app is told it is fullscreen, tile stays) | | ⌘ **T** | Toggle floating / tiled | | ⌘ **O** | Pop the window out: float it at 1300×900, center, pin on top on every workspace; again to tile it back | diff --git a/desktop/README.md b/desktop/README.md index 67229ba1..419b89b9 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -421,6 +421,7 @@ Phone builds of this package always link Reference and Sheets and the octos kern - Eight desktop styles. Desktop builds start in **OctoSense**, with Liquid Glass frames and a **Light / Dark** switch in the top bar; the others are Omarchy, macOS, Windows, Windows 2000, NeXTSTEP, iOS and Android. Theme sources are in `../crates/shell/resources/themes/`, wallpaper provenance in [crates/shell/resources/wallpapers/README.md](../crates/shell/resources/wallpapers/README.md). - **Full screen.** A window's green button or ⌘F makes it full screen, as on macOS. It covers the whole screen, and the top bar, its own title bar and the dock hide. Rest the pointer on the top edge for the bar and the window's title bar (whose green button leaves full screen), or on the bottom edge for the dock. Each goes back shortly after the pointer leaves it, and stays out while a menu it opened is showing. To maximize instead, above the dock, Option-click the green button, press ⌘⌥F or double-click the title bar. +- **An app's presentation.** An app can make its own window full screen from a tap or click, for a slideshow or a reading mode (`window.fullscreen`). Then no edge brings the bar, the title bar or the dock out, and the OctoSense window enters full screen as well, unless it is already, and leaves it again afterwards. **Esc** ends the presentation, and a hint says so for three seconds. Entering full screen on a real display this way is **unverified**. - **The dock in full screen.** When the OctoSense window itself is full screen (on macOS, the green button or ⌃⌘F), the OctoSense and macOS styles hide their dock the same way, as macOS hides its Dock beside a full-screen app; meanwhile new windows may use the full height. In a window with nothing full screen, the dock always shows. Under Wayland a maximized window counts as full screen; makepad reports no full screen on Windows or X11, so there the dock hides only for a full-screen window inside OctoSense. A hidden shell gets each behaviour with `MAKEPAD_HIDE_WINDOWS=1 MAKEPAD_REMOTE=127.0.0.1:8932 target/debug/octosense --test-action launch-calculator --test-action fullscreen` (a window) or `--test-action host-fullscreen` (the OctoSense window). On a real display, both are **unverified**. - Keys: **⌘Space** menu, **⌘W** close tile, **⌘F** tile fullscreen, **⌘1…0** workspaces, **⌘Shift1…0** move tile. On macOS, **⌘A** selects all in the focused app; **Ctrl+Alt+A** remains the window manager's alternate-layer prefix. **Learn → Keybindings** lists them; see [KEYBINDINGS.md](KEYBINDINGS.md). - State lives in `~/.octosense` (`OCTOSENSE_HOME`); hosted apps get it as `MAKEPAD_HOME`. diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index bafea994..a07921f1 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -406,6 +406,7 @@ cargo makepad android run -p octosense --release - 八种桌面样式。桌面构建启动时使用 **OctoSense** 样式,带 Liquid Glass 窗框,顶栏有 **Light / Dark** 切换;其余样式为 Omarchy、macOS、Windows、Windows 2000、NeXTSTEP、iOS 和 Android。主题源文件在 `../crates/shell/resources/themes/`,壁纸来源见 [crates/shell/resources/wallpapers/README.md](../crates/shell/resources/wallpapers/README.md)。 - **全屏。** 点窗口的绿色按钮或按 ⌘F,窗口会像在 macOS 上一样进入全屏:它覆盖整个屏幕,顶栏、它自己的标题栏和程序坞都会隐藏。把指针停在屏幕顶边,顶栏和窗口标题栏就会出现(标题栏上的绿色按钮可退出全屏);停在底边,程序坞就会升起。指针离开后不久它们会再次收起,而它们打开的菜单显示期间会保持出现。若想改为最大化(停在程序坞之上),可按住 Option 点绿色按钮、按 ⌘⌥F 或双击标题栏。 +- **应用的演示全屏。** 应用可以在用户点按或点击时让自己的窗口进入全屏,用于幻灯片放映或阅读模式(`window.fullscreen`)。此时屏幕边缘不会唤出顶栏、标题栏或程序坞;OctoSense 窗口如果还不在全屏中,也会进入全屏,结束后再退出。按 **Esc** 结束演示全屏,提示会显示三秒。以这种方式在真实显示器上进入全屏尚**未经验证**。 - **全屏时的程序坞。** OctoSense 窗口本身全屏时(在 macOS 上点绿色按钮或按 ⌃⌘F),OctoSense 和 macOS 样式也会这样隐藏程序坞,就像 macOS 在全屏应用旁隐藏 Dock 一样;此时新窗口可以占用全部高度。没有任何全屏时,程序坞始终显示。在 Wayland 下,最大化的窗口也算全屏;makepad 在 Windows 和 X11 上不报告全屏,所以在那里只有 OctoSense 内的全屏窗口会让程序坞隐藏。隐藏窗口运行的 shell 可用 `MAKEPAD_HIDE_WINDOWS=1 MAKEPAD_REMOTE=127.0.0.1:8932 target/debug/octosense --test-action launch-calculator --test-action fullscreen`(窗口全屏)或 `--test-action host-fullscreen`(OctoSense 窗口全屏)获得这些行为。在真实显示器上,两者都尚**未经验证**。 - 快捷键:**⌘Space** 菜单,**⌘W** 关闭 tile,**⌘F** tile 全屏,**⌘1…0** 切换工作区,**⌘Shift1…0** 移动 tile。在 macOS 上,**⌘A** 交给当前应用执行全选;**Ctrl+Alt+A** 仍用于窗口管理器的备用修饰层前缀。**Learn → Keybindings** 列出全部快捷键;见 [KEYBINDINGS.md](KEYBINDINGS.md)。 - 状态保存在 `~/.octosense`(`OCTOSENSE_HOME`);被托管的应用通过 `MAKEPAD_HOME` 获得它。 diff --git a/desktop/docs/code-walkthrough.md b/desktop/docs/code-walkthrough.md index be99fc65..6126115c 100644 --- a/desktop/docs/code-walkthrough.md +++ b/desktop/docs/code-walkthrough.md @@ -154,6 +154,54 @@ window fills the whole work area. Restoring brings back the window's own rect. With a dock that reserves its strip (Windows) or no dock, a maximized window fills the whole work area, as before. +A full-screen window, from ⌘F or the green button in the macOS-family +styles, covers the whole desk. `DesktopWindow::fullscreen` is kept apart from +`maximized`, and `WmLayout::rects_of` gives the window the rect past the +outer gap, which `WmDesk::draw_walk` sets from the desk's own rect every +frame. `WmDesk::draw_tile` draws it as content alone. The bar and a floating +dock hide (`desktop::front_fullscreen`) until the pointer rests on the top or +bottom edge (`edge_reveal.rs`, `App::top_edge`, `App::place_bar`). Leaving +full screen gives back the window's rect, zoom and place in the stack, which +never changed. + +A script app puts its own window in presentation full screen with +`host.request("window.fullscreen", {on: true}, fn(r){…})`, from the handler +of the person's tap or click ([`app_fullscreen.rs`](../../crates/shell/src/app_fullscreen.rs)). +The shell's `window` host service queues the request. After the event, +`App::serve_window_requests` looks for the window whose Card runner runs the +isolate that asked, for that app (`App::window_of`), so an app reaches only +its own window. It refuses a request when the person has not pressed the app +in that window in the last five seconds (`Activation`): makepad cannot tell +which handler made a `host.request`, so the shell keeps a transient +activation, as a browser does. Only `MpRunViewAction::Pressed` counts, which +`MpModuleView` sends for a press on the module's live root with no host +service's sheet up over it; a group tab, a stopped app's face and a process +tile send only `Clicked`, which focuses. A presentation that ends any other +way than the app's own `{on: false}` drops the window's press +(`Presenter::finish`), so the app cannot bounce back from its hook. It +refuses `{on: true}` from an agent's tool call too: App Hub's Card runner +takes a request's surface authority (`ServiceCall::may_prompt`) away while +one of the app's tool calls runs, and never because of a capability. +`WmLayout::set_fullscreen` then makes that window full +screen, and `WmState::presentation` keeps every edge from bringing the bar, +the title bar or the dock out (`desktop::dock_shows`, `App::desktop_pointer`), +as in a slideshow's play mode. The shell's own window enters the host's full +screen if it is not there already (`HostFullscreen`, through the call the +bar's own window control makes: macOS's native full screen, makepad's +maximize elsewhere; makepad's `Window::fullscreen` does nothing on macOS +yet), and the OSD says "Press Esc to exit full screen" for three seconds. +These end it: Esc, `{on: false}`, ⌘F, the window closing or losing the +focus, the app's module failing, a style switch, or the person leaving the +host's full screen. `App::sync_presentation` checks after every event, and +the host window leaves full screen only if the presentation entered it, even +when it gets there up to 20 seconds after the presentation ended +(`PENDING_LEAVE_SECONDS`). The answer is +`{applied, fullscreen, fills_screen}`, and the app's optional +`on_app_fullscreen(on)` hook hears both ends. On a phone the request changes +nothing (`fills_screen: true`). A hidden shell (`MAKEPAD_HIDE_WINDOWS`) never +asks the host window to go full screen, so entering macOS's full screen on a +real display is **unverified**; so is the maximize on Windows and Linux. + Admission verifies the bundle and resolves its metadata and resource limits. Capability names disclose usage, not execution permission. A script's `host.request(...)` runs under that app's identity. App Hub's diff --git a/desktop/docs/code-walkthrough.zh-CN.md b/desktop/docs/code-walkthrough.zh-CN.md index 586a277e..3a98cf8f 100644 --- a/desktop/docs/code-walkthrough.zh-CN.md +++ b/desktop/docs/code-walkthrough.zh-CN.md @@ -122,6 +122,40 @@ MAKEPAD_APP_CONFIG='{"mail_demo":true}' cargo run --locked --release -p octosens 还原时窗口回到它自己的位置和尺寸。程序坞预留区域(Windows)或没有程序坞时,最大化的窗口 像以前一样填满整个工作区。 +按 ⌘F 或在 macOS 系列样式中点绿色按钮,窗口进入全屏,铺满整个桌面。`DesktopWindow::fullscreen` +与 `maximized` 相互独立,`WmLayout::rects_of` 给窗口越过外边距的矩形,这块矩形由 +`WmDesk::draw_walk` 每帧按桌面自身的矩形设置。`WmDesk::draw_tile` 只绘制窗口内容。顶栏和浮动程序坞 +隐藏(`desktop::front_fullscreen`),直到指针停在屏幕顶边或底边(`edge_reveal.rs`、 +`App::top_edge`、`App::place_bar`)。退出全屏时,窗口的矩形、缩放状态和它在窗口堆叠中的位置都 +原样恢复,因为它们从未改变。 + +脚本应用在用户点按或点击的处理函数中调用 +`host.request("window.fullscreen", {on: true}, fn(r){…})`,让自己的窗口进入演示全屏 +([`app_fullscreen.rs`](../../crates/shell/src/app_fullscreen.rs))。Shell 的 `window` +宿主服务把请求排入队列。事件处理完后,`App::serve_window_requests` 查找由该应用的 Card +运行器运行发出请求的那个 isolate 的窗口(`App::window_of`),所以应用只能改动自己的窗口。 +用户在最近五秒内没有在这个窗口里按过应用时,请求被拒绝(`Activation`):makepad 无法分辨 +`host.request` 来自哪个处理函数,所以 Shell 像浏览器一样保留一个短暂的激活状态。只有 +`MpRunViewAction::Pressed` 才算数:`MpModuleView` 在按下落在模块的活动根部件上、且上方没有 +宿主服务面板时发出它;分组标签、已停止应用的关闭画面和进程窗口只发出用于切换焦点的 `Clicked`。 +演示全屏以应用自己的 `{on: false}` 以外的方式结束时,窗口的按下记录随之作废 +(`Presenter::finish`),所以应用不能在钩子里立刻弹回全屏。来自 Agent 工具调用的 +`{on: true}` 也会被拒绝:App Hub 的 Card 运行器在应用的工具调用进行期间收回请求的界面权限 +(`ServiceCall::may_prompt`),而且从不因为某个能力而收回。随后 +`WmLayout::set_fullscreen` 让这个窗口进入全屏,`WmState::presentation` 让任何屏幕边缘都 +不再唤出顶栏、标题栏或程序坞(`desktop::dock_shows`、`App::desktop_pointer`),就像幻灯片的 +放映模式。Shell 自己的窗口如果还不在宿主全屏中,就进入全屏(`HostFullscreen`,使用栏上窗口 +控制按钮的同一个调用:macOS 上是原生全屏,其他平台是 makepad 的最大化;makepad 的 +`Window::fullscreen` 在 macOS 上目前不起作用),OSD 显示 "Press Esc to exit full screen" +三秒。以下任一情况结束演示全屏:Esc、`{on: false}`、⌘F、窗口关闭或失去焦点、应用模块出错、 +切换风格、用户离开宿主全屏。`App::sync_presentation` 在每个事件之后检查;只有这次演示全屏让 +宿主窗口进入了全屏时,宿主窗口才退出全屏,即使它在演示结束之后 20 秒内才进入全屏 +(`PENDING_LEAVE_SECONDS`)。回答是 +`{applied, fullscreen, fills_screen}`,应用可选的 +`on_app_fullscreen(on)` 钩子在开始和结束时都会收到通知。在手机上,这个请求不改变任何东西 +(`fills_screen: true`)。隐藏窗口运行的 Shell(`MAKEPAD_HIDE_WINDOWS`)从不让宿主窗口进入 +全屏,所以在真实显示器上进入 macOS 全屏**尚未验证**;Windows 和 Linux 上的最大化同样未验证。 + 准入验证应用包,解析元数据与资源上限。能力名称披露用途,不是执行许可。脚本的 `host.request(...)` 按应用身份执行。 App Hub 的 `crates/appstore/src/services.rs` 定义 `HostService`、`ServiceCall` 和回复机制; 调用携带应用身份和宿主目录,供服务检查。只有明确声明的 Agent 工具,才会把相应操作提供给模型。 diff --git a/docs/host-os-api-status.md b/docs/host-os-api-status.md index f175cb90..35e6fc0f 100644 --- a/docs/host-os-api-status.md +++ b/docs/host-os-api-status.md @@ -35,6 +35,7 @@ Unsupported capture backends emit `CameraCaptureResult::Failed` instead of silen | Audio and generated media | Makepad has native audio input/output, voice-input plumbing and the `Video` playback widget. `model.audio` already produces speech; `model.image`, video jobs and embeddings also exist. | Provider generation is separate from OS recording and playback. These media methods do not prove a live provider call or device playback succeeded. | | Embedded pages and authentication | `WebReader::open_on_platform` has macOS, iOS, Android, Linux and Windows adapters. Host-owned `auth` services and provider/backend login flows already exist. | External-link repair does not replace embedded browsing or authentication. WebView availability, provider registration, redirect policy and platform login validation remain distinct requirements. Apps never receive the host's credentials. | | Notifications and background work | `glance.publish` can request a card notification; `App::glance_notify` posts shell toasts and the Home shade. Mail/connected Gmail have specific background execution and durable event/outbox paths. | This is not a general OS alarm, push-token or arbitrary closed-app script scheduler. Background work needs a defined event contract, app authority and OS lifecycle support; a notification alone supplies none of those. | +| Windows and full screen | On main after RC2: `window.fullscreen {on}` puts the calling app's own window in presentation full screen from the person's tap or click: the window covers the desk with no title bar, the bar and dock hide with no edge bringing them back, and the shell's window enters the host's full screen unless it is already there. A press on the app in that window within 5 seconds is required, because makepad cannot tell which handler made a request, and a request from an agent's tool call is refused (no capability decides either); Esc and a three-second "Press Esc to exit full screen" hint are the shell's. The answer is `{applied, fullscreen, fills_screen}`, and the optional `on_app_fullscreen(on)` hook hears both ends. A phone answers without changing anything. See `app_fullscreen.rs` in [crates/shell](../crates/shell/src/app_fullscreen.rs). | The method is not described to `runtime.list` yet: App Hub's contract has no capability a window method could name, so apps cannot list it in `host_api` until App Hub adds one. Entering macOS full screen on a real display, and makepad's maximize on Windows and Linux, are **unverified**: hidden test shells never ask the host window. | | Clipboard and sharing | Native text editing/clipboard operations exist. Policed WebCard exposes `clipboard.write` under its own grant. The new `files.share` service exposes Android text sharing to contained apps. | General clipboard reads, attachment sharing and cross-platform share sheets remain separate. Chooser handoff is not recipient delivery; native declarations do not imply every backend implements them. | Source entry points: [CalendarService and its owner check](../apps/calendar/host-service/src/lib.rs), [connected-account contract](../crates/oauth-service/README.md#app-facing-contract), [AuthService](../crates/oauth-service/src/host.rs), [device service](../crates/shell/src/platform_services/mod.rs), [model media contract](../apps/ai-providers/host-service/MEDIA.md), [Glance](../crates/shell/src/glance.rs), [`App::glance_notify`](../crates/shell/src/lib.rs), [mail_background](../crates/shell/src/mail_background.rs) and [connected_events](../crates/shell/src/connected_events.rs). Relevant Makepad symbols are `CameraPreview::record_start`, `WindowVoiceInput::ensure_audio_callback`, `CxMediaApi`, `Video`, `WebReader::open_on_platform`, `web_card::policed_tool_grant` and `Cx::share_text`. diff --git a/docs/host-os-api-status.zh-CN.md b/docs/host-os-api-status.zh-CN.md index e0bbe37b..b0ec07a7 100644 --- a/docs/host-os-api-status.zh-CN.md +++ b/docs/host-os-api-status.zh-CN.md @@ -35,6 +35,7 @@ | 音频与生成媒体 | Makepad 已有原生音频输入、输出,语音输入链路及 `Video` 播放控件。`model.audio` 已能请求生成语音;`model.image`、视频任务、embeddings 也已实现。 | 服务商生成与 OS 录制、播放是不同环节。有这些方法不代表已通过真实服务商调用或设备播放验证。 | | 嵌入页面、身份认证 | `WebReader::open_on_platform` 已有 macOS、iOS、Android、Linux、Windows 适配器;宿主持有的 `auth` 服务及服务商、应用后端登录流程也已存在。 | 外部链接修复不会代替嵌入式浏览或身份认证。WebView 可用性、服务商注册、跳转策略及各平台登录验收仍分别成立;应用始终拿不到宿主凭据。 | | 通知、后台工作 | `glance.publish` 可请求卡片通知;`App::glance_notify` 显示 Shell toast 和 Home 通知栏通知。Mail、已连接 Gmail 有特定后台执行及持久事件、通知发件箱路径。 | 这些不是通用 OS 闹钟、推送 token 或任意关闭应用的脚本调度器。后台任务还需要明确的事件契约、应用权限和 OS 生命周期支持,通知本身不提供这些能力。 | +| 窗口与全屏 | RC2 之后在 main 上:`window.fullscreen {on}` 在用户点按或点击时让发出请求的应用自己的窗口进入演示全屏:窗口铺满桌面且没有标题栏,Shell 的栏和程序坞隐藏,屏幕边缘也不会把它们唤出,Shell 自己的窗口如不在宿主全屏中则进入全屏。请求前 5 秒内必须在这个窗口里按过应用,因为 makepad 无法分辨请求来自哪个处理函数;来自 Agent 工具调用的请求会被拒绝(两者都不由能力决定);Esc 和三秒钟的 "Press Esc to exit full screen" 提示由 Shell 提供。回答是 `{applied, fullscreen, fills_screen}`,可选的 `on_app_fullscreen(on)` 钩子在开始和结束时都会收到通知。手机上的请求不改变任何东西就回答。见 [crates/shell](../crates/shell/src/app_fullscreen.rs) 中的 `app_fullscreen.rs`。 | 这个方法还没有向 `runtime.list` 描述:App Hub 的契约里没有窗口方法可以对应的能力,所以在 App Hub 增加一个之前,应用无法在 `host_api` 中列出它。在真实显示器上进入 macOS 全屏,以及 Windows 和 Linux 上 makepad 的最大化,都**尚未验证**:隐藏窗口的测试 Shell 从不让宿主窗口进入全屏。 | | 剪贴板、分享 | 原生文本编辑和剪贴板操作已存在;受限 WebCard 按自己的授权提供 `clipboard.write`。新增 `files.share` 把 Android 文本分享接到受限应用。 | 通用剪贴板读取、附件分享及跨平台分享面板仍是独立工作。选择器交接不等于收件人收到;原生声明不表示每个平台都已实现。 | 源码入口:[CalendarService 及归属检查](../apps/calendar/host-service/src/lib.rs)、[连接账户契约](../crates/oauth-service/README.zh-CN.md)、[AuthService](../crates/oauth-service/src/host.rs)、[设备服务](../crates/shell/src/platform_services/mod.rs)、[模型媒体契约](../apps/ai-providers/host-service/MEDIA.zh-CN.md)、[Glance](../crates/shell/src/glance.rs)、[`App::glance_notify`](../crates/shell/src/lib.rs)、[mail_background](../crates/shell/src/mail_background.rs)、[connected_events](../crates/shell/src/connected_events.rs)。相关 Makepad 符号为 `CameraPreview::record_start`、`WindowVoiceInput::ensure_audio_callback`、`CxMediaApi`、`Video`、`WebReader::open_on_platform`、`web_card::policed_tool_grant`、`Cx::share_text`。