mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 01:00:39 +00:00
M9: publish to a server from the browser (Share > Online and Publish to server in the web build, #917) (#1567)
The web transport implements LiveLink::publish and the hosting and sharing background requests over the page's WebSocket. Managing connections run as exchanges (exchange.rs) in the browser client's driver; the requests and what their answers mean are shared with the desktop transport (requests.rs). The Share popover offers online sharing in the browser (no local network), and Publish to server opens there. Fixes #917.
This commit is contained in:
1 parent
704fb0b717
commit
3c1718bf86
40 files changed
+1085
-257
No files matched your search
+3
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a tLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers paneLine truncated
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -59,7 +59,7 @@ native speakers' reviews of the nine language drafts need people.
|
||||
| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins and publishes to a server, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
|
||||
@@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M9, #917: the browser build publishes to a `figcraft-server`. Share ▸ Online (the server in Preferences) and Share ▸ Publish to server… upload the open file over the page's own WebSocket, make the links and join codes others join with, and keep the tab in sync with the new file, as the desktop app does; asking a server about its hosting, stopping a share and sharing again go out the same way. The Share popover offers only online sharing there (a page can't listen, so the local network stays the desktop app's); without a server in Preferences it says so and offers joining. A failed publish says why and leaves the file as it was. The browser keeps only your name across reloads, so the owner token of a file it put on an open server lasts as long as the page (`crates/collab-net/src/exchange.rs`, run by the browser client's driver; the requests and what their answers mean are shared with the desktop transport in `requests.rs`). Tests: `exchange_tests` (scripted server: with and without the key, sharing or not, refusals, an old server without join codes, time-outs, leaving while publishing, the background requests), `live_join_tests`; checked in a browser against a local server. Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M4, #1051: fonts installed on this computer in the browser build. Where the browser lists them (Chromium's Local Font Access, `window.queryLocalFonts`), the Design panel's font menu offers "Use fonts installed on this computer…"; the browser asks for permission, and the fonts join the font list by name, with their styles. A font's bytes are read only when text uses it, one style at a time (its preview in the menu waits until then), and auto-sizing text fits it once it is in; one that can't be read is dropped and its text keeps the fallback. A family the build embeds keeps its faces; the computer's styles it lacks join it. Other browsers show no row (`figcraft_text::add_local_fonts`, `apps/figcraft-web/src/local_fonts.rs`). Tests: `fontdb::local_tests`, `local_fonts_tests`; checked in a browser. Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M8, #1550: SVG import reads CSS colour functions its SVG parser doesn't (it painted them black, or left strokes out, without a word): `color(srgb …)` and `color(display-p3 …)`, with alpha, converted to the document's profile, and `color-mix(in srgb|oklab|oklch, …)`, evaluated at import (percentages, alpha and nested mixes as CSS Color 5 has them), in colour attributes, `style` attributes and `<style>` sheets. Other colour spaces are taken as sRGB values and other mixes mixed in sRGB, each with an import note; a call that can't be read becomes a mid grey with a note. Bounded: calls over 2,048 characters, mixes nested more than 8 deep and more than 100,000 calls are not read. Tests: `css_color_tests` (svg). Numbers unchanged |
|
||||
| 2026-10-30 | minor | M1, #1320: settings in portable mode. The settings folder now comes from craft-settings, the suite's rules: `FIGCRAFT_CONFIG_DIR` names a folder (set but empty: none, nothing read or written); a `portable` file next to the executable (next to the `.app` on macOS) puts the preferences, recovery copies and installed plugins in `config/` beside it; otherwise they stay in `FigCraft` in the platform folder, where every earlier version kept them, so nothing moves for an installed app. The Windows portable zip now ships the `portable` file. A portable folder without preferences yet starts with a copy of the platform folder's `state.json` (owner-only, the original left for an installed app), so a portable copy that kept its settings in `%APPDATA%` before keeps them after the update. Checked on Windows 11 with a portable copy beside an installed copy's settings: the first start took them (its name and start screen), a name changed there was kept in `config\` only, and without the marker the app read the installed settings again. Tests: `settings_dir_tests`. Numbers unchanged |
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
//! - fonts: the built-in ones, and on request (the font menu's "Use fonts installed on this
|
||||
//! computer…", Chromium's Local Font Access) the computer's, each read when text uses it
|
||||
//! (`local_fonts.rs`);
|
||||
//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't
|
||||
//! host one. A page served over `https://` can only reach sessions on the same computer
|
||||
//! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to
|
||||
//! join from other machines.
|
||||
//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) and files on
|
||||
//! a `figcraft-server`, and publishes files to a server (Share ▸ Online, Share ▸ Publish to
|
||||
//! server…), but can't host a session. A page served over `https://` reaches only `wss://`
|
||||
//! servers and the same computer (browsers block `ws://` from secure pages); serve it over
|
||||
//! `http://` on a trusted network to reach plain `ws://` hosts on other machines.
|
||||
//!
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
|
||||
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
|
||||
|
||||
@@ -39,8 +39,8 @@ pub fn start() {
|
||||
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
let mut session = Session::new();
|
||||
crate::kept::restore_name(&mut session, local_storage().and_then(|s| s.get_item(NAME_KEY).ok().flatten()));
|
||||
// Live sessions: the browser joins sessions a desktop app hosts; whatever arrives
|
||||
// on the socket wakes the page for a frame.
|
||||
// Live sessions: the browser joins sessions and publishes files to servers;
|
||||
// whatever arrives on the socket wakes the page for a frame.
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
|
||||
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
|
||||
|
||||
+136
-21
@@ -10,14 +10,22 @@
|
||||
//! A join by code ([`GuestConfig::code`]) first asks the server which file the code opens on a
|
||||
//! connection of its own, then joins that file as with a link; a server that doesn't know the
|
||||
//! code (or can't be reached) ends the join with the reason.
|
||||
//!
|
||||
//! Publishing ([`Driver::publish`]) and the requests sent in the background
|
||||
//! ([`Driver::asking`]) first ask a server things on managing connections of their own (see
|
||||
//! `exchange.rs`), each waiting for its answers as long as a large upload may take; a publish then
|
||||
//! joins the new file.
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, PROTOCOL, Presence, Resolve, ServerMsg};
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
// `std::time::Instant` on native builds; the browser's clock in the web build.
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::exchange::{self, Exchange, Then};
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::Endpoint;
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
|
||||
use crate::requests::Publish;
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
|
||||
@@ -63,13 +71,14 @@ pub(crate) struct Driver<S: Socket> {
|
||||
pending: Option<Presence>,
|
||||
throttle: Throttle<Presence>,
|
||||
reconnect_at: Option<Instant>,
|
||||
/// What this connection asks a server (a managing one), and what follows its answers.
|
||||
asking: Option<(Box<Exchange>, Then)>,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl<S: Socket> Driver<S> {
|
||||
/// Start joining (progress arrives in `inbox`).
|
||||
pub(crate) fn start(sock: S, cfg: GuestConfig, inbox: Inbox, now: Instant) -> Self {
|
||||
let mut d = Self {
|
||||
fn new(sock: S, cfg: GuestConfig, inbox: Inbox) -> Self {
|
||||
Self {
|
||||
sock,
|
||||
cfg,
|
||||
inbox,
|
||||
@@ -80,47 +89,132 @@ impl<S: Socket> Driver<S> {
|
||||
pending: None,
|
||||
throttle: Throttle::new(PRESENCE_INTERVAL),
|
||||
reconnect_at: None,
|
||||
asking: None,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Start joining (progress arrives in `inbox`).
|
||||
pub(crate) fn start(sock: S, cfg: GuestConfig, inbox: Inbox, now: Instant) -> Self {
|
||||
let mut d = Self::new(sock, cfg, inbox);
|
||||
d.inbox.push(Event::Status(Status::Connecting));
|
||||
d.connect(now);
|
||||
d
|
||||
}
|
||||
|
||||
/// Start with what `next` asks a server (a background request): the events it ends in go to
|
||||
/// `inbox`, then the join it ends in, if any, as `cfg` says.
|
||||
pub(crate) fn asking(sock: S, cfg: GuestConfig, next: exchange::Next, inbox: Inbox, now: Instant) -> Self {
|
||||
let mut d = Self::new(sock, cfg, inbox);
|
||||
d.follow(next, now);
|
||||
d
|
||||
}
|
||||
|
||||
/// Start publishing `job`, then join the new file as `cfg` says (its invite is the new file's).
|
||||
pub(crate) fn publish(sock: S, cfg: GuestConfig, job: Publish, inbox: Inbox, now: Instant) -> Self {
|
||||
inbox.push(Event::Status(Status::Connecting));
|
||||
Self::asking(sock, cfg, exchange::publish(job), inbox, now)
|
||||
}
|
||||
|
||||
/// The session is over (left, refused, ended or given up): nothing more will happen.
|
||||
pub(crate) fn done(&self) -> bool {
|
||||
self.done
|
||||
}
|
||||
|
||||
fn connect(&mut self, now: Instant) {
|
||||
self.reconnect_at = None;
|
||||
self.conn = Some(Conn::new(now));
|
||||
if let Err(e) = self.sock.open(&self.cfg.invite.endpoint()) {
|
||||
self.end(Outcome::Retry(e), now);
|
||||
/// Ask the next server, or tell the UI and join (or stop).
|
||||
fn follow(&mut self, next: exchange::Next, now: Instant) {
|
||||
match next {
|
||||
exchange::Next::Ask(ex, then) => {
|
||||
self.asking = Some((ex, then));
|
||||
self.connect(now);
|
||||
}
|
||||
exchange::Next::Done(events, join) => {
|
||||
for e in events {
|
||||
self.inbox.push(e);
|
||||
}
|
||||
match join {
|
||||
Some(invite) => {
|
||||
self.cfg.invite = invite;
|
||||
self.connect(now);
|
||||
}
|
||||
None => self.done = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection is open: say hello (or ask about the join code first).
|
||||
/// The server this connection is to.
|
||||
fn server(&self) -> &Endpoint {
|
||||
self.asking.as_ref().map_or(&self.cfg.invite.server, |(ex, _)| &ex.server)
|
||||
}
|
||||
|
||||
fn connect(&mut self, now: Instant) {
|
||||
self.reconnect_at = None;
|
||||
self.conn = Some(Conn::new(now));
|
||||
if let Err(e) = self.sock.open(&self.server().url()) {
|
||||
self.dropped(e, now);
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection broke for this reason: a managing one fails, a session's tries again.
|
||||
fn dropped(&mut self, why: String, now: Instant) {
|
||||
if self.asking.is_some() { self.answered(Err(why), now) } else { self.end(Outcome::Retry(why), now) }
|
||||
}
|
||||
|
||||
/// The managing connection is over (with why it failed): close it, then what follows.
|
||||
fn answered(&mut self, r: Result<(), String>, now: Instant) {
|
||||
let Some((ex, then)) = self.asking.take() else { return };
|
||||
if self.conn.take().is_some() {
|
||||
self.sock.close();
|
||||
}
|
||||
let answers = match r {
|
||||
Ok(()) => Ok(ex.answers()),
|
||||
Err(e) => Err(ex.failed(e)),
|
||||
};
|
||||
self.follow(then(answers), now);
|
||||
}
|
||||
|
||||
/// The connection is open: say hello (or ask about the join code first, or open what is asked).
|
||||
pub(crate) fn opened(&mut self, now: Instant) {
|
||||
let Some(c) = self.conn.as_mut() else { return };
|
||||
c.opened = true;
|
||||
c.last_heard = now;
|
||||
let hello = match &self.cfg.code {
|
||||
Some(code) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
|
||||
None => hello(&self.cfg, &self.resume),
|
||||
let hello = match (&self.asking, &self.cfg.code) {
|
||||
(Some((ex, _)), _) => ex.opening().clone(),
|
||||
(None, Some(code)) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
|
||||
(None, None) => hello(&self.cfg, &self.resume),
|
||||
};
|
||||
if !self.write(&hello) {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
self.dropped("the connection dropped".into(), now);
|
||||
}
|
||||
}
|
||||
|
||||
/// A binary message from the host.
|
||||
/// A binary message on a managing connection: the next request, or what follows the answers.
|
||||
fn answer(&mut self, bytes: &[u8], now: Instant) {
|
||||
let Some((ex, _)) = self.asking.as_mut() else { return };
|
||||
let next = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
|
||||
Ok(m) => ex.take(m),
|
||||
Err(e) => Err(format!("the server sent a message this version can't read ({e})")),
|
||||
};
|
||||
let done = ex.done();
|
||||
match next {
|
||||
Err(e) => self.answered(Err(e), now),
|
||||
Ok(Some(req)) if !self.write(&req) => self.answered(Err("the connection to the server dropped".into()), now),
|
||||
Ok(_) if done => self.answered(Ok(()), now),
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A binary message from the host (or the server asked).
|
||||
pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) {
|
||||
let Some(c) = self.conn.as_mut() else { return };
|
||||
c.last_heard = now;
|
||||
if self.asking.is_some() {
|
||||
return self.answer(bytes, now);
|
||||
}
|
||||
if bytes.len() > self.cfg.limits.max_server_message {
|
||||
return self.end(too_large(&self.cfg.limits), now);
|
||||
}
|
||||
c.last_heard = now;
|
||||
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
|
||||
// The join code's answer: join the file it opens, on a new connection.
|
||||
Ok(ServerMsg::Resolved { file, token }) if self.cfg.code.is_some() => {
|
||||
@@ -155,12 +249,23 @@ impl<S: Socket> Driver<S> {
|
||||
if self.conn.is_none() {
|
||||
return;
|
||||
}
|
||||
if self.asking.is_some() {
|
||||
return self.answered(Err("the server sent a text message".into()), now);
|
||||
}
|
||||
self.end(Outcome::Refused("the host sent a text message".into()), now);
|
||||
}
|
||||
|
||||
/// The connection closed (`code` and `reason` from the close frame; 1006 when it just broke).
|
||||
pub(crate) fn closed(&mut self, code: u16, reason: &str, now: Instant) {
|
||||
let Some(opened) = self.conn.as_ref().map(|c| c.opened) else { return };
|
||||
if self.asking.is_some() {
|
||||
let why = match (opened, reason) {
|
||||
(false, _) => format!("no answer from {}", self.server().display()),
|
||||
(true, "") => "the server closed the connection".to_string(),
|
||||
(true, r) => format!("the server closed the connection: {r}"),
|
||||
};
|
||||
return self.answered(Err(why), now);
|
||||
}
|
||||
let outcome = match closed(code, reason) {
|
||||
Outcome::Retry(_) if !opened => Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())),
|
||||
Outcome::Retry(_) if code == 1006 => Outcome::Retry("the connection dropped".into()),
|
||||
@@ -205,11 +310,12 @@ impl<S: Socket> Driver<S> {
|
||||
return None;
|
||||
}
|
||||
let l = &self.cfg.limits;
|
||||
let conn = self.conn.as_ref().map(|c| {
|
||||
if c.opened {
|
||||
let conn = self.conn.as_ref().map(|c| match (c.opened, self.asking.is_some()) {
|
||||
(false, _) => [Some(c.started + self.cfg.connect_timeout), None, None],
|
||||
// A managing connection waits for each answer.
|
||||
(true, true) => [Some(c.last_heard + l.request_reply_timeout), None, None],
|
||||
(true, false) => {
|
||||
[Some(c.last_ping + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
|
||||
} else {
|
||||
[Some(c.started + self.cfg.connect_timeout), None, None]
|
||||
}
|
||||
});
|
||||
earliest(conn.into_iter().flatten().chain([self.reconnect_at]))
|
||||
@@ -229,7 +335,13 @@ impl<S: Socket> Driver<S> {
|
||||
let (opened, started, last_heard, last_ping) = (c.opened, c.started, c.last_heard, c.last_ping);
|
||||
if !opened {
|
||||
if now >= started + self.cfg.connect_timeout {
|
||||
self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), now);
|
||||
self.dropped(format!("no answer from {}", self.server().display()), now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.asking.is_some() {
|
||||
if now >= last_heard + l.request_reply_timeout {
|
||||
self.answered(Err("no answer from the server".into()), now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -258,6 +370,9 @@ impl<S: Socket> Driver<S> {
|
||||
|
||||
/// The connection ended: close it, then stop or plan the reconnect.
|
||||
fn end(&mut self, outcome: Outcome, now: Instant) {
|
||||
// Left while asking a server: what it was asked stays done (a file uploaded stays there),
|
||||
// nothing follows.
|
||||
self.asking = None;
|
||||
// Still asking about the join code: whatever went wrong is final.
|
||||
let outcome = if self.cfg.code.is_some() { unresolved(&self.cfg, outcome) } else { outcome };
|
||||
if self.conn.take().is_some() {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Requests to a server outside a session for the browser client, free of the browser so they run
|
||||
//! (and are tested) on any target: an [`Exchange`] is one managing connection (its opening, then
|
||||
//! requests answered in order, as `manage.rs` does on a blocking socket), and a [`Next`] says what
|
||||
//! follows its answers: another exchange, or the events for the UI and the file to join. The
|
||||
//! [`Driver`](crate::driver::Driver) runs them over its socket.
|
||||
//!
|
||||
//! Publishing ([`publish`]) uploads the document, makes the links and codes others join with when
|
||||
//! it shares, then joins the new file with its edit link; [`background`] sends the hosting and
|
||||
//! sharing requests the engine sends in the background (asking a server about its hosting,
|
||||
//! starting and stopping a share).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, HostInfo, Manage, OpenHosting, PROTOCOL, ServerMsg};
|
||||
use figcraft_engine::live::{Event, NetReply, NetRequest, Shared, Status};
|
||||
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::requests::{self, Publish, REFUSED};
|
||||
|
||||
/// One managing connection to a server: what opens it, then requests answered in order.
|
||||
pub(crate) struct Exchange {
|
||||
pub(crate) server: Endpoint,
|
||||
open: ClientMsg,
|
||||
requests: VecDeque<Admin>,
|
||||
answers: Answers,
|
||||
/// The server let the connection in (its first answer came).
|
||||
ready: bool,
|
||||
/// A request is out.
|
||||
asked: bool,
|
||||
}
|
||||
|
||||
/// What an exchange got: the server's hosting (opened without the key) and the answers to the
|
||||
/// requests, in order.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Answers {
|
||||
pub(crate) hosting: Option<HostInfo>,
|
||||
pub(crate) replies: Vec<AdminReply>,
|
||||
}
|
||||
|
||||
/// What follows an exchange's answers (or why it failed).
|
||||
pub(crate) type Then = Box<dyn FnOnce(Result<Answers, String>) -> Next>;
|
||||
|
||||
/// What comes next.
|
||||
pub(crate) enum Next {
|
||||
/// Another exchange, on a connection of its own.
|
||||
Ask(Box<Exchange>, Then),
|
||||
/// Done: tell the UI these, then join the file this invite names (`None`: nothing to join).
|
||||
Done(Vec<Event>, Option<Invite>),
|
||||
}
|
||||
|
||||
impl Exchange {
|
||||
/// Ask `server` `requests`: with its `key`, else (open hosting) as anyone, or as the owner of
|
||||
/// `file` with its `owner` token.
|
||||
pub(crate) fn new(server: Endpoint, key: Option<&str>, file: &str, owner: &str, requests: Vec<Admin>) -> Self {
|
||||
let open = match key {
|
||||
Some(key) => {
|
||||
ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() })
|
||||
}
|
||||
None => ClientMsg::OpenHosting(OpenHosting {
|
||||
protocol: PROTOCOL,
|
||||
client_version: message::client_version(),
|
||||
file: file.to_string(),
|
||||
owner: owner.to_string(),
|
||||
}),
|
||||
};
|
||||
Self { server, open, requests: requests.into(), answers: Answers::default(), ready: false, asked: false }
|
||||
}
|
||||
|
||||
/// The connection's first message.
|
||||
pub(crate) fn opening(&self) -> &ClientMsg {
|
||||
&self.open
|
||||
}
|
||||
|
||||
/// Every request is answered.
|
||||
pub(crate) fn done(&self) -> bool {
|
||||
self.ready && !self.asked && self.requests.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn answers(self) -> Answers {
|
||||
self.answers
|
||||
}
|
||||
|
||||
/// Why the exchange failed with `e`, as the app says it: a server older than open hosting
|
||||
/// can't read the opening.
|
||||
pub(crate) fn failed(&self, e: String) -> String {
|
||||
if self.ready || !matches!(self.open, ClientMsg::OpenHosting(_)) { e } else { requests::hosting_error(&self.server, e) }
|
||||
}
|
||||
|
||||
/// Take in one message from the server: the next request to send, if one is due; an error
|
||||
/// ends the exchange.
|
||||
pub(crate) fn take(&mut self, m: ServerMsg) -> Result<Option<ClientMsg>, String> {
|
||||
match m {
|
||||
ServerMsg::Refused { reason } => return Err(self.failed(format!("{REFUSED}{reason}"))),
|
||||
ServerMsg::Pong => return Ok(None),
|
||||
ServerMsg::Admin(r) if self.asked => self.answers.replies.push(*r),
|
||||
ServerMsg::Admin(r) if !self.ready && matches!(self.open, ClientMsg::Manage(_)) => match *r {
|
||||
AdminReply::Ready => self.ready = true,
|
||||
other => return Err(format!("the server answered {other:?} instead of letting this connection manage files")),
|
||||
},
|
||||
ServerMsg::Hosting(info) if !self.ready && matches!(self.open, ClientMsg::OpenHosting(_)) => {
|
||||
self.ready = true;
|
||||
self.answers.hosting = Some(*info);
|
||||
}
|
||||
_ => return Err("the server sent something other than an answer".into()),
|
||||
}
|
||||
self.asked = false;
|
||||
Ok(self.requests.pop_front().map(|r| {
|
||||
self.asked = true;
|
||||
ClientMsg::Admin(r)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// The UI's failure: why the file couldn't be published (it stays as it was).
|
||||
fn not_published(e: &str) -> Next {
|
||||
Next::Done(vec![Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })], None)
|
||||
}
|
||||
|
||||
/// Publish `job` (see the module docs): [`Event::Hosting`] (published without the key),
|
||||
/// [`Event::Published`] and, to share, [`Event::Shared`], then the join.
|
||||
pub(crate) fn publish(job: Publish) -> Next {
|
||||
let Publish { server, key, name, doc, share } = job;
|
||||
let upload = match requests::upload(&name, &doc) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return not_published(&e),
|
||||
};
|
||||
let ex = Exchange::new(server.clone(), key.as_deref(), "", "", vec![upload]);
|
||||
Next::Ask(
|
||||
Box::new(ex),
|
||||
Box::new(move |answers| {
|
||||
let (hosting, uploaded) = match answers {
|
||||
Ok(a) => (a.hosting, requests::uploaded(&server, a.replies.into_iter().next())),
|
||||
Err(e) => (None, Err(e)),
|
||||
};
|
||||
let (edit, published) = match uploaded {
|
||||
Ok(done) => done,
|
||||
Err(e) => return not_published(&e),
|
||||
};
|
||||
let mut events: Vec<Event> = hosting.map(Event::Hosting).into_iter().collect();
|
||||
events.push(Event::Published(published.clone()));
|
||||
if !share {
|
||||
return Next::Done(events, Some(edit));
|
||||
}
|
||||
// The file's first links stay its owner's (this app joins with one): others get their
|
||||
// own, which stopping takes away again.
|
||||
let owner = published.owner.unwrap_or_default();
|
||||
let then: Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next> = Box::new(move |made| {
|
||||
events.push(Event::Shared(made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string()))));
|
||||
Next::Done(events, Some(edit))
|
||||
});
|
||||
share_links(Sharing { server, key, file: published.file, owner, revoke: Vec::new(), create: true }, then)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// What [`share_links`] changes: who else gets into `file` (as `manage::share` does).
|
||||
struct Sharing {
|
||||
server: Endpoint,
|
||||
key: Option<String>,
|
||||
file: String,
|
||||
owner: String,
|
||||
revoke: Vec<String>,
|
||||
create: bool,
|
||||
}
|
||||
|
||||
/// Revoke the links `s.revoke` names, then (to create) make new ones and ask for their codes on a
|
||||
/// connection of their own; `then` gets the links.
|
||||
fn share_links(s: Sharing, then: Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next>) -> Next {
|
||||
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::link_requests(&s.file, &s.revoke, s.create));
|
||||
Next::Ask(
|
||||
Box::new(ex),
|
||||
Box::new(move |answers| {
|
||||
let links = match answers.and_then(|a| requests::made_links(a.replies, s.create)) {
|
||||
Ok(Some(links)) => links,
|
||||
other => return then(other.map(|_| None)),
|
||||
};
|
||||
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::code_requests(&s.file, &links));
|
||||
Next::Ask(
|
||||
Box::new(ex),
|
||||
Box::new(move |codes| {
|
||||
// A server from before join codes: the links are shared without them.
|
||||
let codes = codes.map_err(|e| log::info!("live session: no join codes from {}: {e}", s.server.display())).unwrap_or_default();
|
||||
then(Ok(Some(requests::shared(&s.server, &s.file, links, codes.replies))))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Background request `req` (answered as [`Event::Net`] with `job`), for the requests the
|
||||
/// browser sends: about a server's hosting, and changing who else gets into a file. `None` for
|
||||
/// the others.
|
||||
pub(crate) fn background(job: u64, req: NetRequest) -> Option<Next> {
|
||||
let answer = move |reply: NetReply| Next::Done(vec![Event::Net { job, reply: Box::new(reply) }], None);
|
||||
Some(match req {
|
||||
NetRequest::Hosting { server, file, owner, req } => match Endpoint::parse(&server) {
|
||||
Err(e) => answer(NetReply::Hosting(Err(e))),
|
||||
Ok((server, _)) => Next::Ask(
|
||||
Box::new(Exchange::new(server.clone(), None, &file, &owner, req.into_iter().collect())),
|
||||
Box::new(move |answers| {
|
||||
let r = answers.and_then(|a| {
|
||||
let info = a.hosting.ok_or("the server didn't say about its hosting")?;
|
||||
Ok((info, requests::hosted(&server, a.replies.into_iter().next())))
|
||||
});
|
||||
answer(NetReply::Hosting(r))
|
||||
}),
|
||||
),
|
||||
},
|
||||
NetRequest::Share { server, file, owner, revoke, create } => match Endpoint::parse(&server) {
|
||||
Err(e) => answer(NetReply::Share(Err(e))),
|
||||
// As the server's operator when the address carries its key, else as the file's owner.
|
||||
Ok((server, query)) => {
|
||||
let key = requests::server_key(None, query);
|
||||
share_links(Sharing { server, key, file, owner, revoke, create }, Box::new(move |r| answer(NetReply::Share(r))))
|
||||
}
|
||||
},
|
||||
NetRequest::Plugins { .. } | NetRequest::Library { .. } | NetRequest::Upload(_) => return None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! The browser's requests to a server outside a session ([`crate::exchange`]), run by its
|
||||
//! [`Driver`] over a scripted socket with a made-up clock: publishing (with and without the
|
||||
//! server's key, sharing or not, then joining the new file), what a failure leaves, and the
|
||||
//! background requests the browser can send.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{self, Access, Admin, AdminReply, ClientMsg, FileInfo, HostInfo, LinkInfo, NewLink, Presence, ServerMsg};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::live::{Event, NetReply, NetRequest, Shared, Status};
|
||||
|
||||
use crate::driver::{Driver, Socket};
|
||||
use crate::exchange::{self, Next};
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::join::GuestConfig;
|
||||
use crate::requests::Publish;
|
||||
|
||||
const FILE: &str = "0123456789abcdef";
|
||||
const TOKEN: &str = "AbCdEfGhIjKlMnOpQrStUv";
|
||||
const SERVER: &str = "ws://127.0.0.1:47910/";
|
||||
|
||||
/// A socket that records what the driver does.
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
opened: Vec<String>,
|
||||
sent: Vec<ClientMsg>,
|
||||
closes: usize,
|
||||
}
|
||||
|
||||
impl Socket for Script {
|
||||
fn open(&mut self, url: &str) -> Result<(), String> {
|
||||
self.opened.push(url.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn send(&mut self, bytes: &[u8]) -> bool {
|
||||
self.sent.push(message::decode(bytes, usize::MAX).unwrap());
|
||||
true
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.closes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn server() -> Endpoint {
|
||||
Endpoint::parse(SERVER).unwrap().0
|
||||
}
|
||||
|
||||
fn job(key: Option<&str>, share: bool) -> Publish {
|
||||
let mut doc = Document::new("Poster");
|
||||
doc.repair();
|
||||
Publish { server: server(), key: key.map(String::from), name: "Poster".into(), doc: Arc::new(doc), share }
|
||||
}
|
||||
|
||||
fn config() -> GuestConfig {
|
||||
GuestConfig::new(Invite { server: server(), file: String::new(), token: String::new() }, "Ada".into())
|
||||
}
|
||||
|
||||
/// A background request's driver.
|
||||
fn run(next: Next, t0: Instant) -> (Driver<Script>, Inbox) {
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
(Driver::asking(Script::default(), config(), next, inbox.clone(), t0), inbox)
|
||||
}
|
||||
|
||||
/// A publish's driver.
|
||||
fn publish(job: Publish, t0: Instant) -> (Driver<Script>, Inbox) {
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
(Driver::publish(Script::default(), config(), job, inbox.clone(), t0), inbox)
|
||||
}
|
||||
|
||||
fn new_link(id: &str, access: Access, token: &str) -> NewLink {
|
||||
NewLink { link: LinkInfo { id: id.into(), file: FILE.into(), access, label: String::new(), created: 0 }, token: token.into() }
|
||||
}
|
||||
|
||||
fn file() -> FileInfo {
|
||||
FileInfo { id: FILE.into(), name: "Poster".into(), created: 0, updated: 0, seq: 0, bytes: 0, people: 0 }
|
||||
}
|
||||
|
||||
fn hosting() -> ServerMsg {
|
||||
ServerMsg::Hosting(Box::new(HostInfo { open: true, operator: "ops@example.com".into(), ..HostInfo::default() }))
|
||||
}
|
||||
|
||||
fn admin(r: AdminReply) -> ServerMsg {
|
||||
ServerMsg::Admin(Box::new(r))
|
||||
}
|
||||
|
||||
fn hosted() -> ServerMsg {
|
||||
admin(AdminReply::Hosted {
|
||||
file: file(),
|
||||
edit: new_link("e0", Access::Edit, TOKEN),
|
||||
view: new_link("v0", Access::View, "ViewViewViewViewViewVi"),
|
||||
owner: "owner-token".into(),
|
||||
expires: Some(9),
|
||||
})
|
||||
}
|
||||
|
||||
/// The server says `m` to the driver.
|
||||
fn say(d: &mut Driver<Script>, m: &ServerMsg, at: Instant) {
|
||||
d.message(&message::encode(m).unwrap(), at);
|
||||
}
|
||||
|
||||
fn last(d: &Driver<Script>) -> &ClientMsg {
|
||||
d.sock.sent.last().unwrap()
|
||||
}
|
||||
|
||||
fn failure(events: &[Event]) -> Option<&str> {
|
||||
events.iter().find_map(|e| if let Event::Status(Status::Failed { reason }) = e { Some(reason.as_str()) } else { None })
|
||||
}
|
||||
|
||||
/// The welcome a server sends Ada in the published file, encoded.
|
||||
fn welcome() -> ServerMsg {
|
||||
let mut doc = Document::new("Poster");
|
||||
doc.repair();
|
||||
let mut server = figcraft_collab::Server::new(doc, TOKEN, figcraft_collab::Limits::default());
|
||||
let ClientMsg::Hello(h) = figcraft_collab::Client::hello(FILE, TOKEN, "Ada", "") else { panic!("no hello") };
|
||||
let (_, outs) = server.join(h).unwrap();
|
||||
outs.into_iter().find_map(|o| if let figcraft_collab::Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap()
|
||||
}
|
||||
|
||||
/// The server makes the links and codes for others, each step checked.
|
||||
fn make_links(d: &mut Driver<Script>, t0: Instant) {
|
||||
// As the file's owner, on a connection of its own.
|
||||
assert_eq!((d.sock.opened.len(), d.sock.closes), (2, 1));
|
||||
d.opened(t0);
|
||||
assert!(matches!(last(d), ClientMsg::OpenHosting(o) if o.file == FILE && o.owner == "owner-token"));
|
||||
say(d, &hosting(), t0);
|
||||
assert!(matches!(last(d), ClientMsg::Admin(Admin::CreateLink { access: Access::Edit, .. })));
|
||||
say(d, &admin(AdminReply::Link(new_link("e1", Access::Edit, "EditEditEditEditEditEd"))), t0);
|
||||
assert!(matches!(last(d), ClientMsg::Admin(Admin::CreateLink { access: Access::View, .. })));
|
||||
say(d, &admin(AdminReply::Link(new_link("v1", Access::View, "SeeSeeSeeSeeSeeSeeSeeS"))), t0);
|
||||
// Their codes, on another.
|
||||
d.opened(t0);
|
||||
say(d, &ServerMsg::Pong, t0);
|
||||
say(d, &hosting(), t0);
|
||||
assert!(matches!(last(d), ClientMsg::Admin(Admin::Code { token, .. }) if token == "EditEditEditEditEditEd"));
|
||||
say(d, &admin(AdminReply::Code { code: "ABCDEFGHJ".into() }), t0);
|
||||
say(d, &admin(AdminReply::Code { code: "KLMNPQRST".into() }), t0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishing_without_the_key_uploads_shares_then_joins() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = publish(job(None, true), t0);
|
||||
assert_eq!(d.sock.opened, vec![SERVER], "no key or token in the URL");
|
||||
assert_eq!(inbox.drain().len(), 1, "connecting");
|
||||
d.opened(t0);
|
||||
assert!(matches!(last(&d), ClientMsg::OpenHosting(o) if o.file.is_empty() && o.owner.is_empty()));
|
||||
// Presence waits for the session.
|
||||
d.presence(Presence { cursor: Some((NodeId(1), 1.0, 2.0)), ..Presence::default() }, t0);
|
||||
say(&mut d, &hosting(), t0);
|
||||
assert!(matches!(last(&d), ClientMsg::Admin(Admin::Publish { name, .. }) if name == "Poster"));
|
||||
say(&mut d, &hosted(), t0);
|
||||
make_links(&mut d, t0);
|
||||
let events = inbox.drain();
|
||||
let [Event::Hosting(h), Event::Published(p), Event::Shared(Ok(Shared { edit, view: Some(view) }))] = events.as_slice() else {
|
||||
panic!("{events:#?}")
|
||||
};
|
||||
assert_eq!(h.operator, "ops@example.com");
|
||||
assert_eq!((p.file.as_str(), p.owner.as_deref(), p.expires), (FILE, Some("owner-token"), Some(9)));
|
||||
assert_eq!((edit.id.as_str(), edit.code.as_deref(), view.code.as_deref()), ("e1", Some("ABCDEFGHJ"), Some("KLMNPQRST")));
|
||||
assert!(edit.link.contains("token=EditEditEditEditEditEd"));
|
||||
// Then the file, with the owner's edit link.
|
||||
assert_eq!(d.sock.opened.len(), 4);
|
||||
d.opened(t0);
|
||||
let ClientMsg::Hello(hello) = last(&d) else { panic!("{:?}", last(&d)) };
|
||||
assert_eq!((hello.file.as_str(), hello.token.as_str()), (FILE, TOKEN));
|
||||
say(&mut d, &welcome(), t0);
|
||||
assert!(matches!(last(&d), ClientMsg::Presence(_)), "the presence that waited goes out with the welcome");
|
||||
assert!(matches!(inbox.drain().first(), Some(Event::Welcome { .. })));
|
||||
assert!(!d.done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishing_with_the_key_joins_without_sharing() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = publish(job(Some("k3y"), false), t0);
|
||||
d.opened(t0);
|
||||
assert!(matches!(last(&d), ClientMsg::Manage(m) if m.key == "k3y"));
|
||||
say(&mut d, &admin(AdminReply::Ready), t0);
|
||||
let published = AdminReply::Published { file: file(), edit: new_link("e0", Access::Edit, TOKEN), view: new_link("v0", Access::View, TOKEN) };
|
||||
say(&mut d, &admin(published), t0);
|
||||
let events = inbox.drain();
|
||||
assert!(matches!(events.as_slice(), [Event::Status(Status::Connecting), Event::Published(p)] if p.owner.is_none()), "{events:#?}");
|
||||
d.opened(t0);
|
||||
assert!(matches!(last(&d), ClientMsg::Hello(h) if h.token == TOKEN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_publish_says_why_and_joins_nothing() {
|
||||
let t0 = Instant::now();
|
||||
let refusals = [
|
||||
(ServerMsg::Refused { reason: "that message couldn't be read".into() }, "takes files only with its key (it is older than this app)"),
|
||||
(ServerMsg::Refused { reason: "this server takes files only with its key".into() }, "the server refused: this server takes files"),
|
||||
(admin(AdminReply::Ready), "something other than an answer"),
|
||||
];
|
||||
for (m, why) in refusals {
|
||||
let (mut d, inbox) = publish(job(None, true), t0);
|
||||
d.opened(t0);
|
||||
say(&mut d, &m, t0);
|
||||
let events = inbox.drain();
|
||||
assert!(failure(&events).is_some_and(|r| r.starts_with("Couldn't publish to the server: ") && r.contains(why)), "{events:#?}");
|
||||
assert!(d.done() && d.sock.opened.len() == 1, "nothing more is tried");
|
||||
}
|
||||
// The upload's own answer fails, or the key is wrong.
|
||||
let (mut d, inbox) = publish(job(Some("bad"), false), t0);
|
||||
d.opened(t0);
|
||||
say(&mut d, &admin(AdminReply::Failed { reason: "the file is too large".into() }), t0);
|
||||
assert!(failure(&inbox.drain()).is_some_and(|r| r.contains("answered Failed")));
|
||||
let (mut d, inbox) = publish(job(Some("k"), false), t0);
|
||||
d.opened(t0);
|
||||
say(&mut d, &admin(AdminReply::Ready), t0);
|
||||
say(&mut d, &admin(AdminReply::Failed { reason: "the file is too large".into() }), t0);
|
||||
assert!(failure(&inbox.drain()).is_some_and(|r| r.ends_with("the file is too large")));
|
||||
// The server goes away, or never answers.
|
||||
let (mut d, inbox) = publish(job(None, false), t0);
|
||||
d.opened(t0);
|
||||
d.closed(1000, "busy", t0);
|
||||
assert!(failure(&inbox.drain()).is_some_and(|r| r.ends_with("the server closed the connection: busy")));
|
||||
let (mut d, inbox) = publish(job(None, false), t0);
|
||||
d.closed(1006, "", t0);
|
||||
assert!(failure(&inbox.drain()).is_some_and(|r| r.contains("no answer from 127.0.0.1:47910")));
|
||||
let (mut d, inbox) = publish(job(None, false), t0);
|
||||
d.opened(t0);
|
||||
d.text(t0);
|
||||
assert!(failure(&inbox.drain()).is_some_and(|r| r.ends_with("the server sent a text message")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_publish_waits_for_the_server_then_gives_up() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = publish(job(None, false), t0);
|
||||
// Until the connection opens, then as long as a large upload may take.
|
||||
assert_eq!(d.deadline(), Some(t0 + Duration::from_secs(5)));
|
||||
d.opened(t0);
|
||||
let wait = crate::limits::NetLimits::default().request_reply_timeout;
|
||||
assert_eq!(d.deadline(), Some(t0 + wait));
|
||||
d.tick(t0 + wait - Duration::from_secs(1));
|
||||
assert!(!d.done());
|
||||
d.tick(t0 + wait);
|
||||
assert!(failure(&inbox.drain()).is_some_and(|r| r.contains("no answer from the server")));
|
||||
assert!(d.done() && d.deadline().is_none());
|
||||
// One that never opens.
|
||||
let (mut d, inbox) = publish(job(None, false), t0);
|
||||
d.tick(t0 + Duration::from_secs(5));
|
||||
assert!(failure(&inbox.drain()).is_some_and(|r| r.contains("no answer from 127.0.0.1:47910")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_while_publishing_stops_there() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = publish(job(None, false), t0);
|
||||
d.opened(t0);
|
||||
inbox.drain();
|
||||
d.leave(t0);
|
||||
assert!(d.done() && d.sock.closes == 1);
|
||||
say(&mut d, &hosting(), t0);
|
||||
assert!(inbox.drain().is_empty(), "nothing more happens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_that_fails_still_joins_the_file() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = publish(job(None, true), t0);
|
||||
d.opened(t0);
|
||||
say(&mut d, &hosting(), t0);
|
||||
say(&mut d, &hosted(), t0);
|
||||
d.opened(t0);
|
||||
say(&mut d, &ServerMsg::Refused { reason: "wrong owner token".into() }, t0);
|
||||
let events = inbox.drain();
|
||||
assert!(matches!(events.last(), Some(Event::Shared(Err(e))) if e.contains("wrong owner token")), "{events:#?}");
|
||||
assert_eq!(d.sock.opened.len(), 3, "the file is joined anyway");
|
||||
// A server from before join codes shares the links without them.
|
||||
let (mut d, inbox) = publish(job(None, true), t0);
|
||||
d.opened(t0);
|
||||
say(&mut d, &hosting(), t0);
|
||||
say(&mut d, &hosted(), t0);
|
||||
d.opened(t0);
|
||||
say(&mut d, &hosting(), t0);
|
||||
say(&mut d, &admin(AdminReply::Link(new_link("e1", Access::Edit, TOKEN))), t0);
|
||||
say(&mut d, &admin(AdminReply::Link(new_link("v1", Access::View, TOKEN))), t0);
|
||||
d.opened(t0);
|
||||
say(&mut d, &hosting(), t0);
|
||||
d.closed(1000, "that message couldn't be read", t0);
|
||||
let events = inbox.drain();
|
||||
assert!(matches!(events.last(), Some(Event::Shared(Ok(s))) if s.edit.code.is_none() && s.view.as_ref().is_some_and(|v| v.code.is_none())));
|
||||
}
|
||||
|
||||
/// Run background request `req` to its answer against `answers` (one per message the driver sends).
|
||||
fn background(req: NetRequest, answers: &[ServerMsg]) -> (NetReply, Vec<ClientMsg>) {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = run(exchange::background(7, req).unwrap(), t0);
|
||||
let mut connections = 0;
|
||||
for m in answers {
|
||||
// Each exchange on a connection of its own: it opens, then the server answers.
|
||||
if d.sock.opened.len() > connections {
|
||||
connections = d.sock.opened.len();
|
||||
d.opened(t0);
|
||||
}
|
||||
say(&mut d, m, t0);
|
||||
}
|
||||
assert!(d.done(), "{:?}", d.sock.sent);
|
||||
let events = inbox.drain();
|
||||
let [Event::Net { job: 7, reply }] = events.as_slice() else { panic!("{events:#?}") };
|
||||
((**reply).clone(), d.sock.sent.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_requests_ask_about_hosting_and_sharing() {
|
||||
let req = |r| NetRequest::Hosting { server: SERVER.into(), file: FILE.into(), owner: "owner-token".into(), req: r };
|
||||
let (reply, sent) = background(req(None), &[hosting()]);
|
||||
assert!(matches!(reply, NetReply::Hosting(Ok((h, None))) if h.open));
|
||||
assert!(matches!(sent.as_slice(), [ClientMsg::OpenHosting(o)] if o.owner == "owner-token"));
|
||||
// A link it makes comes back whole.
|
||||
let create = Admin::CreateLink { file: FILE.into(), access: Access::View, label: String::new() };
|
||||
let (reply, _) = background(req(Some(create)), &[hosting(), admin(AdminReply::Link(new_link("v2", Access::View, TOKEN)))]);
|
||||
assert!(
|
||||
matches!(reply, NetReply::Hosting(Ok((_, Some(AdminReply::Link(l))))) if l.token.starts_with("figcraft://join?") && l.token.contains(TOKEN))
|
||||
);
|
||||
// Stopping a share revokes its links, with the key when the address carries it.
|
||||
let stop =
|
||||
NetRequest::Share { server: format!("{SERVER}?key=k3y"), file: FILE.into(), owner: String::new(), revoke: vec!["e1".into()], create: false };
|
||||
let (reply, sent) = background(stop, &[admin(AdminReply::Ready), admin(AdminReply::Done)]);
|
||||
assert!(matches!(reply, NetReply::Share(Ok(None))));
|
||||
assert!(matches!(sent.as_slice(), [ClientMsg::Manage(m), ClientMsg::Admin(Admin::Revoke { link, .. })] if m.key == "k3y" && link == "e1"));
|
||||
// Addresses that aren't one answer at once; what the browser doesn't send stays the engine's.
|
||||
let bad_hosting = NetRequest::Hosting { server: "nope".into(), file: String::new(), owner: String::new(), req: None };
|
||||
let bad_share = NetRequest::Share { server: "http://x".into(), file: FILE.into(), owner: String::new(), revoke: Vec::new(), create: true };
|
||||
for bad in [bad_hosting, bad_share] {
|
||||
let Some(Next::Done(events, None)) = exchange::background(1, bad) else { panic!("not answered at once") };
|
||||
assert!(matches!(events.as_slice(), [Event::Net { reply, .. }] if matches!(**reply, NetReply::Hosting(Err(_)) | NetReply::Share(Err(_)))));
|
||||
}
|
||||
let plugins = NetRequest::Plugins { server: SERVER.into(), req: figcraft_collab::message::PluginReq::List { query: String::new() } };
|
||||
assert!(exchange::background(1, plugins).is_none());
|
||||
}
|
||||
@@ -16,8 +16,7 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, TryRecvError, channel}
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::Replica;
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, HostInfo, Presence, ServerMsg};
|
||||
use figcraft_collab::message::{self, ClientMsg, HostInfo, Presence, ServerMsg};
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_engine::live::{Event, Published, Status};
|
||||
use tungstenite::protocol::frame::coding::CloseCode;
|
||||
@@ -30,20 +29,10 @@ pub use crate::join::GuestConfig;
|
||||
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
|
||||
use crate::manage;
|
||||
use crate::net::{Duplex, NetLimits, config, open, shutdown};
|
||||
use crate::requests;
|
||||
pub use crate::requests::Publish;
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
/// What [`Guest::publish`] uploads, and where.
|
||||
pub struct Publish {
|
||||
pub server: Endpoint,
|
||||
/// The server's key (`None`: publish as anyone may, on a server open to the public).
|
||||
pub key: Option<String>,
|
||||
/// The file's name on the server.
|
||||
pub name: String,
|
||||
pub doc: Arc<Document>,
|
||||
/// Share the file once it is there: links and join codes for others ([`Event::Shared`]).
|
||||
pub share: bool,
|
||||
}
|
||||
|
||||
/// A joined (or joining) session. Dropping it leaves.
|
||||
pub struct Guest {
|
||||
cmd: Sender<Cmd>,
|
||||
@@ -183,8 +172,7 @@ pub(crate) fn upload_file(
|
||||
limits: &NetLimits,
|
||||
timeout: Duration,
|
||||
) -> Result<(Invite, Published, Option<HostInfo>), String> {
|
||||
let snapshot = Replica::new(doc.clone()).snapshot().map_err(|e| e.to_string())?;
|
||||
let publish = vec![Admin::Publish { name: name.to_string(), snapshot }];
|
||||
let publish = vec![requests::upload(name, doc)?];
|
||||
let (replies, hosting) = match key {
|
||||
Some(key) => (manage::request(server, key, publish, limits, timeout)?, None),
|
||||
None => {
|
||||
@@ -192,16 +180,7 @@ pub(crate) fn upload_file(
|
||||
(replies, Some(info))
|
||||
}
|
||||
};
|
||||
let (file, edit, view, owner, expires) = match replies.into_iter().next() {
|
||||
Some(AdminReply::Published { file, edit, view }) => (file, edit, view, None, None),
|
||||
Some(AdminReply::Hosted { file, edit, view, owner, expires }) => (file, edit, view, Some(owner), expires),
|
||||
Some(AdminReply::Failed { reason }) => return Err(reason),
|
||||
_ => return Err("the server didn't say where the file is".into()),
|
||||
};
|
||||
let link = |token: String| Invite { server: server.clone(), file: file.id.clone(), token };
|
||||
let (edit, view) = (link(edit.token), link(view.token));
|
||||
let published =
|
||||
Published { server: server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link(), owner, expires };
|
||||
let (edit, published) = requests::uploaded(server, replies.into_iter().next())?;
|
||||
Ok((edit, published, hosting))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,20 @@
|
||||
//! engine's `collab.*` commands drive it and the UI reads the engine's live state. Network
|
||||
//! threads wake the UI through a [`Waker`] only when something arrives; nothing polls.
|
||||
//!
|
||||
//! The browser build's [`NetLink`] joins sessions through the browser's WebSocket API (`web-sys`);
|
||||
//! hosting needs the desktop app. Its connection logic (`driver.rs`) is target-independent and
|
||||
//! tested natively.
|
||||
//! The browser build's [`NetLink`] joins sessions and publishes files to servers through the
|
||||
//! browser's WebSocket API (`web-sys`); hosting a session needs the desktop app. Its connection
|
||||
//! logic (`driver.rs`, `exchange.rs`) is target-independent and tested natively.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod driver;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod exchange;
|
||||
pub mod inbox;
|
||||
pub mod invite;
|
||||
mod join;
|
||||
pub mod limits;
|
||||
mod requests;
|
||||
mod sessions;
|
||||
pub mod throttle;
|
||||
|
||||
@@ -57,6 +60,8 @@ mod code_tests;
|
||||
#[cfg(test)]
|
||||
mod driver_tests;
|
||||
#[cfg(test)]
|
||||
mod exchange_tests;
|
||||
#[cfg(test)]
|
||||
mod invite_tests;
|
||||
#[cfg(test)]
|
||||
mod links_tests;
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::inbox::{Inbox, Waker};
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::manage;
|
||||
use crate::net::NetLimits;
|
||||
use crate::requests;
|
||||
use crate::sessions::Sessions;
|
||||
use crate::token;
|
||||
|
||||
@@ -176,7 +177,7 @@ impl LiveLink for NetLink {
|
||||
fn publish(&mut self, session: SessionId, o: PublishOptions) -> Result<(), String> {
|
||||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
// Without a key: as anyone may, when the server is open to the public (it says if not).
|
||||
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
|
||||
let key = requests::server_key(o.key, query);
|
||||
// Until the upload is done the invite names only the server.
|
||||
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, None, &o.me);
|
||||
let inbox = self.sessions.inbox();
|
||||
@@ -287,8 +288,8 @@ impl LiveLink for NetLink {
|
||||
/// Keep `o.doc` on a server as a new file (see [`LiveLink::upload`]).
|
||||
fn upload(o: PublishOptions, limits: &NetLimits) -> Result<Published, String> {
|
||||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
|
||||
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
|
||||
let key = requests::server_key(o.key, query)
|
||||
.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
|
||||
upload_file(&server, Some(&key), &o.name, &o.doc, limits, CONNECT).map(|(_, published, _)| published)
|
||||
}
|
||||
|
||||
@@ -297,21 +298,14 @@ fn upload(o: PublishOptions, limits: &NetLimits) -> Result<Published, String> {
|
||||
fn hosting(server: &str, file: &str, owner: &str, req: Option<Admin>, limits: &NetLimits) -> Result<(HostInfo, Option<AdminReply>), String> {
|
||||
let (endpoint, _) = Endpoint::parse(server)?;
|
||||
let (info, replies) = manage::hosting(&endpoint, file, owner, req.into_iter().collect(), limits, CONNECT)?;
|
||||
let reply = replies.into_iter().next().map(|r| match r {
|
||||
AdminReply::Link(mut l) => {
|
||||
l.token = Invite { server: endpoint.clone(), file: l.link.file.clone(), token: l.token }.link();
|
||||
AdminReply::Link(l)
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
Ok((info, reply))
|
||||
Ok((info, requests::hosted(&endpoint, replies.into_iter().next())))
|
||||
}
|
||||
|
||||
/// See [`LiveLink::share`]: as the server's operator when the address carries its key, else as
|
||||
/// the file's owner.
|
||||
fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, limits: &NetLimits) -> Result<Option<Shared>, String> {
|
||||
let (endpoint, query) = Endpoint::parse(server)?;
|
||||
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).filter(|k| !k.trim().is_empty());
|
||||
let key = requests::server_key(None, query);
|
||||
manage::share(&endpoint, key.as_deref(), file, owner, revoke, create, limits, CONNECT)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,16 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{
|
||||
self, Access, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
|
||||
PluginReply, PluginReq, Resolve, ServerMsg,
|
||||
self, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply,
|
||||
PluginReq, Resolve, ServerMsg,
|
||||
};
|
||||
use figcraft_engine::live::{Shared, SharedLink};
|
||||
use figcraft_engine::live::Shared;
|
||||
use tungstenite::{Bytes, Message, WebSocket};
|
||||
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::net::{Duplex, NetLimits, config, open};
|
||||
pub(crate) use crate::requests::REFUSED;
|
||||
use crate::requests::{code_requests, hosting_error, link_requests, made_links, shared};
|
||||
|
||||
type Ws = WebSocket<Duplex>;
|
||||
|
||||
@@ -62,16 +64,11 @@ pub fn hosting(
|
||||
let open = OpenHosting { protocol: PROTOCOL, client_version: message::client_version(), file: file.to_string(), owner: owner.to_string() };
|
||||
let mut ws = connect(server, limits, timeout)?;
|
||||
send(&mut ws, &ClientMsg::OpenHosting(open))?;
|
||||
let info = match reply(&mut ws, limits, |m| match m {
|
||||
let info = reply(&mut ws, limits, |m| match m {
|
||||
ServerMsg::Hosting(i) => Some(*i),
|
||||
_ => None,
|
||||
}) {
|
||||
Ok(i) => i,
|
||||
Err(e) if e.contains("couldn't be read") => {
|
||||
return Err(format!("{} takes files only with its key (it is older than this app): add ?key=… to the address", server.display()));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
})
|
||||
.map_err(|e| hosting_error(server, e))?;
|
||||
let mut replies = Vec::with_capacity(requests.len());
|
||||
for r in requests {
|
||||
send(&mut ws, &ClientMsg::Admin(r))?;
|
||||
@@ -116,37 +113,12 @@ pub fn share(
|
||||
Some(key) => request(server, key, requests, limits, timeout),
|
||||
None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies),
|
||||
};
|
||||
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
|
||||
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
|
||||
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
|
||||
let mut made = Vec::new();
|
||||
for reply in ask(requests)? {
|
||||
match reply {
|
||||
AdminReply::Link(l) => made.push(l),
|
||||
// A link that is gone already is as revoked as it gets.
|
||||
AdminReply::Failed { reason } if made.is_empty() && reason.contains("no such link") => {}
|
||||
AdminReply::Failed { reason } => return Err(reason),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let (Some(view), Some(edit)) = (made.pop(), made.pop()) else {
|
||||
return if create { Err("the server didn't make the links".into()) } else { Ok(None) };
|
||||
};
|
||||
let Some(links) = made_links(ask(link_requests(file, revoke, create))?, create)? else { return Ok(None) };
|
||||
// Their codes, on a connection of its own: a server from before join codes can't read the
|
||||
// request and closes it (the links are shared without codes then).
|
||||
let code = |token: &str| Admin::Code { file: file.to_string(), token: token.to_string() };
|
||||
let mut codes = ask(vec![code(&edit.token), code(&view.token)])
|
||||
.map_err(|e| log::info!("live session: no join codes from {}: {e}", server.display()))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
|
||||
let mut shared = |l: NewLink| SharedLink {
|
||||
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
|
||||
id: l.link.id,
|
||||
code: codes.next().flatten(),
|
||||
};
|
||||
let edit = shared(edit);
|
||||
Ok(Some(Shared { edit, view: Some(shared(view)) }))
|
||||
let codes =
|
||||
ask(code_requests(file, &links)).map_err(|e| log::info!("live session: no join codes from {}: {e}", server.display())).unwrap_or_default();
|
||||
Ok(Some(shared(server, file, links, codes)))
|
||||
}
|
||||
|
||||
/// Send one library request about the file behind share link `link`; the reply. Fails when the
|
||||
@@ -225,9 +197,6 @@ fn send(ws: &mut Ws, msg: &ClientMsg) -> Result<(), String> {
|
||||
ws.send(Message::Binary(Bytes::from(bytes))).map_err(|e| format!("the connection to the server dropped ({e})"))
|
||||
}
|
||||
|
||||
/// How an error starts when the server said no (then its reason), rather than not answering.
|
||||
pub(crate) const REFUSED: &str = "the server refused: ";
|
||||
|
||||
/// The next answer `pick` takes, or why there is none.
|
||||
fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<T>) -> Result<T, String> {
|
||||
loop {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! What the app asks a server outside a session, and what the answers mean, free of the
|
||||
//! transport: the native one (`manage.rs`, `guest.rs`) and the browser's (`exchange.rs`) send the
|
||||
//! same requests and read the answers the same way.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_collab::Replica;
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, NewLink};
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_engine::live::{Published, Shared, SharedLink};
|
||||
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
|
||||
/// What publishing uploads, and where.
|
||||
pub struct Publish {
|
||||
pub server: Endpoint,
|
||||
/// The server's key (`None`: publish as anyone may, on a server open to the public).
|
||||
pub key: Option<String>,
|
||||
/// The file's name on the server.
|
||||
pub name: String,
|
||||
pub doc: Arc<Document>,
|
||||
/// Share the file once it is there: links and join codes for others
|
||||
/// ([`Event::Shared`](figcraft_engine::live::Event::Shared)).
|
||||
pub share: bool,
|
||||
}
|
||||
|
||||
/// How an error starts when the server said no (then its reason), rather than not answering.
|
||||
pub(crate) const REFUSED: &str = "the server refused: ";
|
||||
|
||||
/// The server's key: the one given, else the address's `?key=…`; a blank one is none.
|
||||
pub(crate) fn server_key(given: Option<String>, query: Vec<(String, String)>) -> Option<String> {
|
||||
given.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Why asking `server` about its hosting failed with `e`: a server older than open hosting can't
|
||||
/// read the question.
|
||||
pub(crate) fn hosting_error(server: &Endpoint, e: String) -> String {
|
||||
if e.contains("couldn't be read") {
|
||||
format!("{} takes files only with its key (it is older than this app): add ?key=… to the address", server.display())
|
||||
} else {
|
||||
e
|
||||
}
|
||||
}
|
||||
|
||||
/// The request that keeps `doc` on a server as a new file named `name`.
|
||||
pub(crate) fn upload(name: &str, doc: &Document) -> Result<Admin, String> {
|
||||
let snapshot = Replica::new(doc.clone()).snapshot().map_err(|e| e.to_string())?;
|
||||
Ok(Admin::Publish { name: name.to_string(), snapshot })
|
||||
}
|
||||
|
||||
/// What `server`'s answer to [`upload`] means: the edit link to join the file with, and where it
|
||||
/// is with its links (and its owner token, published without the key).
|
||||
pub(crate) fn uploaded(server: &Endpoint, reply: Option<AdminReply>) -> Result<(Invite, Published), String> {
|
||||
let (file, edit, view, owner, expires) = match reply {
|
||||
Some(AdminReply::Published { file, edit, view }) => (file, edit, view, None, None),
|
||||
Some(AdminReply::Hosted { file, edit, view, owner, expires }) => (file, edit, view, Some(owner), expires),
|
||||
Some(AdminReply::Failed { reason }) => return Err(reason),
|
||||
_ => return Err("the server didn't say where the file is".into()),
|
||||
};
|
||||
let link = |token: String| Invite { server: server.clone(), file: file.id.clone(), token };
|
||||
let (edit, view) = (link(edit.token), link(view.token));
|
||||
let published = Published { server: server.display(), file: file.id, name: file.name, edit: edit.link(), view: view.link(), owner, expires };
|
||||
Ok((edit, published))
|
||||
}
|
||||
|
||||
/// The requests that revoke `file`'s links with the ids in `revoke`, then, to `create`, make a new
|
||||
/// edit link and a new view link.
|
||||
pub(crate) fn link_requests(file: &str, revoke: &[String], create: bool) -> Vec<Admin> {
|
||||
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
|
||||
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
|
||||
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
|
||||
requests
|
||||
}
|
||||
|
||||
/// The edit and view links the answers to [`link_requests`] made (`None`: none were asked for),
|
||||
/// or why the server didn't do it.
|
||||
pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Option<(NewLink, NewLink)>, String> {
|
||||
let mut made = Vec::new();
|
||||
for reply in replies {
|
||||
match reply {
|
||||
AdminReply::Link(l) => made.push(l),
|
||||
// A link that is gone already is as revoked as it gets.
|
||||
AdminReply::Failed { reason } if made.is_empty() && reason.contains("no such link") => {}
|
||||
AdminReply::Failed { reason } => return Err(reason),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match (made.pop(), made.pop()) {
|
||||
(Some(view), Some(edit)) => Ok(Some((edit, view))),
|
||||
_ if create => Err("the server didn't make the links".into()),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The requests for the join codes of the links [`made_links`] gave (asked on a connection of
|
||||
/// their own: a server from before join codes can't read them and closes it).
|
||||
pub(crate) fn code_requests(file: &str, (edit, view): &(NewLink, NewLink)) -> Vec<Admin> {
|
||||
[edit, view].into_iter().map(|l| Admin::Code { file: file.to_string(), token: l.token.clone() }).collect()
|
||||
}
|
||||
|
||||
/// What others join `file` on `server` with: the links, with the codes `codes` (the answers to
|
||||
/// [`code_requests`]; none from a server older than join codes).
|
||||
pub(crate) fn shared(server: &Endpoint, file: &str, (edit, view): (NewLink, NewLink), codes: Vec<AdminReply>) -> Shared {
|
||||
let mut codes = codes.into_iter().map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
|
||||
let mut link = |l: NewLink| SharedLink {
|
||||
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
|
||||
id: l.link.id,
|
||||
code: codes.next().flatten(),
|
||||
};
|
||||
let edit = link(edit);
|
||||
Shared { edit, view: Some(link(view)) }
|
||||
}
|
||||
|
||||
/// A hosting answer as the app shows it: a new link whole, in place of its token.
|
||||
pub(crate) fn hosted(server: &Endpoint, reply: Option<AdminReply>) -> Option<AdminReply> {
|
||||
reply.map(|r| match r {
|
||||
AdminReply::Link(mut l) => {
|
||||
l.token = Invite { server: server.clone(), file: l.link.file.clone(), token: l.token }.link();
|
||||
AdminReply::Link(l)
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
@@ -1,33 +1,36 @@
|
||||
//! The browser client (step 4 of `docs/collab-protocol.md`): the web build joins a session a
|
||||
//! desktop app hosts, or a file on a `figcraft-server` (also over `wss://` behind a TLS reverse
|
||||
//! proxy), through the browser's WebSocket API (`web-sys`), with the same messages as the native
|
||||
//! guest. The connection logic is the [`Driver`]'s; this file feeds it the socket's
|
||||
//! events and wakes it with a timer. Hosting needs the desktop app (a page can't listen on a
|
||||
//! socket).
|
||||
//! proxy), and publishes files to a server, through the browser's WebSocket API (`web-sys`), with
|
||||
//! the same messages as the native guest. The connection logic is the [`Driver`]'s; this file
|
||||
//! feeds it the socket's events and wakes it with a timer. Hosting a session needs the desktop
|
||||
//! app (a page can't listen on a socket).
|
||||
//!
|
||||
//! The page runs everything on one thread: each session's socket, callbacks and timer live in a
|
||||
//! thread-local table, and [`NetLink`] (the engine needs it to be `Send`) holds only each session's
|
||||
//! number there and its [`Inbox`](crate::inbox::Inbox). It joins any number of sessions at once
|
||||
//! (one per open document). Whatever arrives wakes egui through the [`Waker`]; nothing polls.
|
||||
//! number there and its [`Inbox`]. It joins any number of sessions at once (one per open
|
||||
//! document), and sends the hosting and sharing requests the engine sends in the background the
|
||||
//! same way. Whatever arrives wakes egui through the [`Waker`]; nothing polls.
|
||||
//!
|
||||
//! An `https://` page can't open an unencrypted `ws://` connection (browsers block mixed content)
|
||||
//! except to this machine; [`NetLink::join`] says so up front.
|
||||
//! except to this machine; [`NetLink::join`] and [`NetLink::publish`] say so up front.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::BTreeMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use figcraft_collab::message::{ClientMsg, Presence};
|
||||
use figcraft_engine::live::{Event, HostOptions, LiveLink, SessionId, SessionInfo};
|
||||
use figcraft_engine::live::{Event, HostOptions, LiveLink, NO_SESSION, NetRequest, PublishOptions, SessionId, SessionInfo};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use web_sys::{BinaryType, CloseEvent, MessageEvent, WebSocket};
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::driver::{Driver, Socket};
|
||||
use crate::inbox::Waker;
|
||||
use crate::invite::Invite;
|
||||
use crate::exchange;
|
||||
use crate::inbox::{Inbox, Waker};
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::join::GuestConfig;
|
||||
use crate::requests::{self, Publish};
|
||||
use crate::sessions::Sessions;
|
||||
|
||||
/// Close code for leaving normally.
|
||||
@@ -134,21 +137,52 @@ fn js_error(e: &wasm_bindgen::JsValue) -> String {
|
||||
e.dyn_ref::<js_sys::Error>().map_or_else(|| format!("{e:?}"), |e| String::from(e.message()))
|
||||
}
|
||||
|
||||
/// The page was loaded over `https://`.
|
||||
fn secure_page() -> bool {
|
||||
web_sys::window().and_then(|w| w.location().protocol().ok()).is_some_and(|p| p == "https:")
|
||||
/// Whether this page may reach `server`: an `https://` page can't open an unencrypted `ws://`
|
||||
/// connection, except to this machine.
|
||||
fn reachable(server: &Endpoint) -> Result<(), String> {
|
||||
let secure_page = web_sys::window().and_then(|w| w.location().protocol().ok()).is_some_and(|p| p == "https:");
|
||||
if secure_page && !server.secure && !server.loopback() {
|
||||
return Err("this page was loaded over https, and browsers block unencrypted ws:// connections; use a wss:// address (a server behind TLS), open FigCraft from an http:// address (like http://localhost:8767), or use the desktop app".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The browser's [`LiveLink`]: joins sessions; hosting needs the desktop app.
|
||||
/// Run `driver` as connection `id` until it is done.
|
||||
fn run(id: u64, driver: Driver<BrowserSocket>) {
|
||||
let timer = Closure::<dyn FnMut()>::new(move || with_driver(id, |d, now| d.tick(now)));
|
||||
let mut g = Guest { driver, timer, timeout: None };
|
||||
g.arm();
|
||||
if !g.driver.done() {
|
||||
GUESTS.with(|t| t.borrow_mut().insert(id, Rc::new(RefCell::new(g))));
|
||||
}
|
||||
}
|
||||
|
||||
/// A new connection's number and its socket.
|
||||
fn socket() -> (u64, BrowserSocket) {
|
||||
let id = NEXT_ID.with(|n| n.replace(n.get() + 1));
|
||||
(id, BrowserSocket { id, ws: None })
|
||||
}
|
||||
|
||||
/// The browser's [`LiveLink`]: joins sessions and publishes files to servers; hosting a session
|
||||
/// needs the desktop app.
|
||||
pub struct NetLink {
|
||||
/// Each session's number in the thread-local table.
|
||||
sessions: Sessions<u64>,
|
||||
/// Answers to requests sent in the background (outlives sessions).
|
||||
background: Inbox,
|
||||
}
|
||||
|
||||
impl NetLink {
|
||||
/// `wake` is called whenever something arrives for the UI (egui's `request_repaint`).
|
||||
pub fn new(wake: Waker) -> Self {
|
||||
Self { sessions: Sessions::new(wake) }
|
||||
Self { background: Inbox::new(wake.clone()), sessions: Sessions::new(wake) }
|
||||
}
|
||||
|
||||
/// Session `session` runs on connection `id` now (the one it had ends).
|
||||
fn started(&mut self, session: SessionId, id: u64, inbox: Inbox) {
|
||||
if let Some(old) = self.sessions.insert(session, id, inbox) {
|
||||
with_driver(old, |d, now| d.leave(now));
|
||||
}
|
||||
}
|
||||
|
||||
fn with(&self, session: SessionId, f: impl FnOnce(&mut Driver<BrowserSocket>, Instant)) {
|
||||
@@ -164,30 +198,44 @@ impl LiveLink for NetLink {
|
||||
}
|
||||
|
||||
fn host(&mut self, _: SessionId, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("hosting a live session needs the desktop app; in the browser you can join one".into())
|
||||
Err("hosting a live session needs the desktop app; in the browser you can join one, or share a file through a server".into())
|
||||
}
|
||||
|
||||
fn join(&mut self, session: SessionId, link: &str, name: &str) -> Result<(), String> {
|
||||
let (invite, code) = Invite::parse_join(link)?;
|
||||
if secure_page() && !invite.server.secure && !invite.loopback() {
|
||||
return Err("this page was loaded over https, and browsers block unencrypted ws:// connections; use a wss:// link (a server behind TLS), open FigCraft from an http:// address (like http://localhost:8767), or join from the desktop app".into());
|
||||
}
|
||||
let id = NEXT_ID.with(|n| n.replace(n.get() + 1));
|
||||
reachable(&invite.server)?;
|
||||
let (id, sock) = socket();
|
||||
let inbox = self.sessions.inbox();
|
||||
let cfg = GuestConfig { code, ..GuestConfig::new(invite, name.to_string()) };
|
||||
let driver = Driver::start(BrowserSocket { id, ws: None }, cfg, inbox.clone(), Instant::now());
|
||||
let timer = Closure::<dyn FnMut()>::new(move || with_driver(id, |d, now| d.tick(now)));
|
||||
let mut g = Guest { driver, timer, timeout: None };
|
||||
g.arm();
|
||||
if !g.driver.done() {
|
||||
GUESTS.with(|t| t.borrow_mut().insert(id, Rc::new(RefCell::new(g))));
|
||||
}
|
||||
if let Some(old) = self.sessions.insert(session, id, inbox) {
|
||||
with_driver(old, |d, now| d.leave(now));
|
||||
}
|
||||
run(id, Driver::start(sock, cfg, inbox.clone(), Instant::now()));
|
||||
self.started(session, id, inbox);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish(&mut self, session: SessionId, o: PublishOptions) -> Result<(), String> {
|
||||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
reachable(&server)?;
|
||||
// Without a key: as anyone may, when the server is open to the public (it says if not).
|
||||
let key = requests::server_key(o.key, query);
|
||||
// Until the upload is done the invite names only the server.
|
||||
let cfg = GuestConfig::new(Invite { server: server.clone(), file: String::new(), token: String::new() }, o.me);
|
||||
let job = Publish { server, key, name: o.name, doc: o.doc, share: o.share };
|
||||
let (id, sock) = socket();
|
||||
let inbox = self.sessions.inbox();
|
||||
run(id, Driver::publish(sock, cfg, job, inbox.clone(), Instant::now()));
|
||||
self.started(session, id, inbox);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn background(&mut self, job: u64, req: NetRequest) -> bool {
|
||||
let Some(next) = exchange::background(job, req) else { return false };
|
||||
let (id, sock) = socket();
|
||||
// The exchanges name their own servers.
|
||||
let cfg = GuestConfig::new(Invite::new("", "", ""), String::new());
|
||||
run(id, Driver::asking(sock, cfg, next, self.background.clone(), Instant::now()));
|
||||
true
|
||||
}
|
||||
|
||||
fn leave(&mut self, session: SessionId) {
|
||||
if let Some(id) = self.sessions.remove(session) {
|
||||
with_driver(id, |d, now| d.leave(now));
|
||||
@@ -203,7 +251,9 @@ impl LiveLink for NetLink {
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Vec<(SessionId, Event)> {
|
||||
self.sessions.poll()
|
||||
let mut out: Vec<(SessionId, Event)> = self.background.drain().into_iter().map(|e| (NO_SESSION, e)).collect();
|
||||
out.extend(self.sessions.poll());
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1113,7 +1113,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "نُشر الإصدار {v} إلى الخادم"
|
||||
"Published version {v}: save the file so other files can use it" = "نُشر الإصدار {v}: احفظ الملف لتتمكن ملفات أخرى من استخدامه"
|
||||
"Published · {people}" = "منشور · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "يتطلب النشر إلى خادم تطبيق سطح المكتب؛ ويمكن الانضمام إلى ملفات الخادم من المتصفح"
|
||||
"Publishing with the server's key." = "جارٍ النشر باستخدام مفتاح الخادم."
|
||||
"Publishing…" = "جارٍ النشر…"
|
||||
"Push" = "دفع"
|
||||
@@ -1318,7 +1317,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "المشاركة: جلسة مباشرة، تصدير"
|
||||
"Shared" = "مشارك"
|
||||
"Shared: copy its code from the file's menu" = "مشارك: انسخ رمزه من قائمة الملف"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "تتطلب مشاركة ملف تطبيق سطح المكتب؛ ويمكنك الانضمام إلى ملف هنا."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "المشاركة عبر الإنترنت متوقفة: لم يُحدد خادم في التفضيلات. يمكنك هنا الانضمام إلى ملف مُشارك."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "المشاركة على الشبكة المحلية (المشاركة عبر الإنترنت متوقفة: لم يُحدد خادم في التفضيلات)."
|
||||
"Sharing on your network · {people}" = "مشارك على شبكتك · {people}"
|
||||
"Sharing online · {people}" = "مشارك على الإنترنت · {people}"
|
||||
@@ -1485,6 +1484,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "دُمج هذا الفرع أو أُرشف."
|
||||
"This computer only" = "هذا الحاسوب فقط"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "يحفظ هذا الحاسوب رمز المالك لإدارة الملف من قائمة «على الإنترنت» في الشاشة الرئيسية. انسخه لإدارة الملف من مكان آخر؛ فهو كلمة مرور لهذا الملف ولن يُعرض مجددًا."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "تحتفظ هذه الصفحة برمز المالك ما دامت مفتوحة فقط. انسخه لإدارة الملف لاحقًا؛ فهو كلمة مرور لهذا الملف ولن يُعرض مجددًا."
|
||||
"This condition is edited as an expression" = "يُحرر هذا الشرط كتعبير"
|
||||
"This file" = "هذا الملف"
|
||||
"This file has unsaved changes" = "يحتوي هذا الملف على تغييرات غير محفوظة"
|
||||
|
||||
@@ -1076,7 +1076,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "সংস্করণ {v} সার্ভারে প্রকাশ করা হয়েছে"
|
||||
"Published version {v}: save the file so other files can use it" = "প্রকাশিত সংস্করণ {v}: ফাইলটি সেভ করুন যাতে অন্যান্য ফাইল এটি ব্যবহার করতে পারে"
|
||||
"Published · {people}" = "প্রকাশিত · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "সার্ভারে প্রকাশ করতে ডেস্কটপ অ্যাপ প্রয়োজন; ব্রাউজারে আপনি সার্ভারের ফাইলে যুক্ত হতে পারবেন"
|
||||
"Publishing with the server's key." = "সার্ভারের কী দিয়ে প্রকাশ করা হচ্ছে।"
|
||||
"Publishing…" = "প্রকাশ করা হচ্ছে..."
|
||||
"Push" = "পুশ"
|
||||
@@ -1274,7 +1273,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "শেয়ার: লাইভ সেশন, এক্সপোর্ট"
|
||||
"Shared" = "শেয়ার করা হয়েছে"
|
||||
"Shared: copy its code from the file's menu" = "শেয়ার করা হয়েছে: ফাইলের মেনু থেকে এর কোড কপি করুন"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "ফাইল শেয়ার করতে ডেস্কটপ অ্যাপ প্রয়োজন; এখানে আপনি একটিতে যুক্ত হতে পারবেন।"
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "অনলাইন শেয়ারিং বন্ধ আছে: প্রেফারেন্সেসে কোনো সার্ভার সেট করা নেই। এখানে আপনি একটি শেয়ার করা ফাইলে যুক্ত হতে পারবেন।"
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "স্থানীয় নেটওয়ার্কে শেয়ার করা হচ্ছে (অনলাইন শেয়ারিং বন্ধ আছে: প্রেফারেন্সেসে কোনো সার্ভার সেট করা নেই)।"
|
||||
"Sharing on your network · {people}" = "আপনার নেটওয়ার্কে শেয়ার করা হচ্ছে · {people}"
|
||||
"Sharing online · {people}" = "অনলাইনে শেয়ার করা হচ্ছে · {people}"
|
||||
@@ -1438,6 +1437,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "এই ব্রাঞ্চটি মার্জ বা আর্কাইভ করা হয়েছে।"
|
||||
"This computer only" = "শুধুমাত্র এই কম্পিউটার"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "এই কম্পিউটার ওনার টোকেন মনে রাখে, তাই হোম স্ক্রিনের অনলাইন তালিকা থেকে ফাইলটি পরিচালনা করা যেতে পারে। অন্য কোথাও থেকে ফাইলটি পরিচালনা করতে এটি কপি করুন: এটি এই ফাইলের জন্য একটি পাসওয়ার্ড এবং আর কখনো দেখানো হবে না।"
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "এই পেজটি খোলা থাকা পর্যন্তই ওনার টোকেন রাখে। পরে ফাইলটি পরিচালনা করতে এটি কপি করুন: এটি এই ফাইলের জন্য একটি পাসওয়ার্ড এবং আর কখনো দেখানো হবে না।"
|
||||
"This condition is edited as an expression" = "এই শর্তটি এক্সপ্রেশন হিসেবে এডিট করা হয়েছে"
|
||||
"This file" = "এই ফাইল"
|
||||
"This file has unsaved changes" = "এই ফাইলে সেভ করা হয়নি এমন পরিবর্তন রয়েছে"
|
||||
|
||||
@@ -1055,7 +1055,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "Verze {v} publikována na server"
|
||||
"Published version {v}: save the file so other files can use it" = "Publikována verze {v}: uložte soubor, aby ji mohly používat ostatní soubory"
|
||||
"Published · {people}" = "Publikováno · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publikování na server vyžaduje desktopovou aplikaci; v prohlížeči se můžete připojit k souborům na serveru"
|
||||
"Publishing with the server's key." = "Publikování s klíčem serveru."
|
||||
"Publishing…" = "Publikování…"
|
||||
"Push" = "Vytlačení"
|
||||
@@ -1249,7 +1248,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "Sdílet: živá relace, export"
|
||||
"Shared" = "Sdíleno"
|
||||
"Shared: copy its code from the file's menu" = "Sdíleno: zkopírujte kód z nabídky souboru"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Sdílení souboru vyžaduje desktopovou aplikaci; zde se můžete k souboru připojit."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Online sdílení je vypnuté: v předvolbách není nastaven server. Zde se můžete připojit ke sdílenému souboru."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Sdílení v místní síti (online sdílení je vypnuté: v předvolbách není nastaven server)."
|
||||
"Sharing on your network · {people}" = "Sdílení ve vaší síti · {people}"
|
||||
"Sharing online · {people}" = "Sdílení online · {people}"
|
||||
@@ -1410,6 +1409,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "Tato větev byla sloučena nebo archivována."
|
||||
"This computer only" = "Pouze tento počítač"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Tento počítač si pamatuje token vlastníka, takže soubor můžete spravovat ze seznamu Online na domovské obrazovce. Zkopírujte ho pro správu souboru odjinud: je to heslo k tomuto souboru a znovu se nezobrazí."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Tato stránka si token vlastníka pamatuje, jen dokud je otevřená. Zkopírujte ho pro pozdější správu souboru: je to heslo k tomuto souboru a znovu se nezobrazí."
|
||||
"This condition is edited as an expression" = "Tato podmínka se upravuje jako výraz"
|
||||
"This file" = "Tento soubor"
|
||||
"This file has unsaved changes" = "Tento soubor obsahuje neuložené změny"
|
||||
|
||||
@@ -1024,7 +1024,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "Version {v} auf dem Server veröffentlicht"
|
||||
"Published version {v}: save the file so other files can use it" = "Version {v} veröffentlicht: Speichern Sie die Datei, damit andere Dateien sie verwenden können"
|
||||
"Published · {people}" = "Veröffentlicht · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Zum Veröffentlichen auf einem Server ist die Desktop-App nötig; im Browser können Sie Dateien eines Servers beitreten"
|
||||
"Publishing with the server's key." = "Veröffentlichung mit dem Schlüssel des Servers."
|
||||
"Publishing…" = "Wird veröffentlicht…"
|
||||
"Push" = "Schieben"
|
||||
@@ -1215,7 +1214,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "Teilen: Live-Sitzung, Export"
|
||||
"Shared" = "Geteilt"
|
||||
"Shared: copy its code from the file's menu" = "Geteilt: Kopieren Sie den Code über das Menü der Datei"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Zum Teilen einer Datei wird die Desktop-App benötigt; hier können Sie einer beitreten."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Online-Teilen ist aus: In den Einstellungen ist kein Server festgelegt. Hier können Sie einer geteilten Datei beitreten."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Teilen im lokalen Netzwerk (Online-Teilen ist aus: In den Einstellungen ist kein Server festgelegt)."
|
||||
"Sharing on your network · {people}" = "Wird in Ihrem Netzwerk geteilt · {people}"
|
||||
"Sharing online · {people}" = "Wird online geteilt · {people}"
|
||||
@@ -1370,6 +1369,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "Dieser Branch wurde zusammengeführt oder archiviert."
|
||||
"This computer only" = "Nur dieser Computer"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Dieser Computer merkt sich das Eigentümer-Token, sodass sich die Datei über die Liste „Online“ auf dem Startbildschirm verwalten lässt. Kopieren Sie es, um die Datei von woanders zu verwalten: Es ist ein Passwort für diese Datei und wird nicht noch einmal angezeigt."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Diese Seite behält das Eigentümer-Token nur, solange sie geöffnet ist. Kopieren Sie es, um die Datei später zu verwalten: Es ist ein Passwort für diese Datei und wird nicht noch einmal angezeigt."
|
||||
"This condition is edited as an expression" = "Diese Bedingung wird als Ausdruck bearbeitet"
|
||||
"This file" = "Diese Datei"
|
||||
"This file has unsaved changes" = "Diese Datei hat nicht gespeicherte Änderungen"
|
||||
|
||||
@@ -1121,7 +1121,6 @@ name = "English"
|
||||
"Published version {v} to the server" = "Published version {v} to the server"
|
||||
"Published version {v}: save the file so other files can use it" = "Published version {v}: save the file so other files can use it"
|
||||
"Published · {people}" = "Published · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publishing to a server needs the desktop app; in the browser you can join a server's files"
|
||||
"Publishing with the server's key." = "Publishing with the server's key."
|
||||
"Publishing…" = "Publishing…"
|
||||
"Push" = "Push"
|
||||
@@ -1327,7 +1326,7 @@ name = "English"
|
||||
"Share: live session, export" = "Share: live session, export"
|
||||
"Shared" = "Shared"
|
||||
"Shared: copy its code from the file's menu" = "Shared: copy its code from the file's menu"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Sharing a file needs the desktop app; here you can join one."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Online sharing is off: no server is set in Preferences. Here you can join a shared file."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Sharing on the local network (online sharing is off: no server is set in Preferences)."
|
||||
"Sharing on your network · {people}" = "Sharing on your network · {people}"
|
||||
"Sharing online · {people}" = "Sharing online · {people}"
|
||||
@@ -1495,6 +1494,7 @@ name = "English"
|
||||
"This branch was merged or archived." = "This branch was merged or archived."
|
||||
"This computer only" = "This computer only"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again."
|
||||
"This condition is edited as an expression" = "This condition is edited as an expression"
|
||||
"This file" = "This file"
|
||||
"This file has unsaved changes" = "This file has unsaved changes"
|
||||
|
||||
@@ -1108,7 +1108,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "Versión {v} publicada en el servidor"
|
||||
"Published version {v}: save the file so other files can use it" = "Versión {v} publicada: guarda el archivo para que otros archivos puedan usarla"
|
||||
"Published · {people}" = "Publicado · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Para publicar en un servidor necesitas la app de escritorio; en el navegador puedes unirte a archivos de un servidor"
|
||||
"Publishing with the server's key." = "Se publica con la clave del servidor."
|
||||
"Publishing…" = "Publicando…"
|
||||
"Push" = "Empujar"
|
||||
@@ -1311,7 +1310,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "Compartir: sesión en vivo, exportar"
|
||||
"Shared" = "Compartido"
|
||||
"Shared: copy its code from the file's menu" = "Compartido: copia su código desde el menú del archivo"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Compartir un archivo requiere la aplicación de escritorio; aquí puedes unirte a uno."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Compartir en línea está desactivado: no hay servidor en Preferencias. Aquí puedes unirte a un archivo compartido."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Se comparte en la red local (compartir en línea está desactivado: no hay servidor en Preferencias)."
|
||||
"Sharing on your network · {people}" = "Compartiendo en tu red · {people}"
|
||||
"Sharing online · {people}" = "Compartiendo en línea · {people}"
|
||||
@@ -1477,6 +1476,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "Esta rama se fusionó o archivó."
|
||||
"This computer only" = "Solo este ordenador"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Este ordenador recuerda el token de propietario, así que el archivo se puede gestionar desde la lista En línea de la pantalla de inicio. Cópialo para gestionar el archivo desde otro lugar: es una contraseña para este archivo y no se volverá a mostrar."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Esta página guarda el token de propietario solo mientras está abierta. Cópialo para gestionar el archivo más tarde: es una contraseña para este archivo y no se volverá a mostrar."
|
||||
"This condition is edited as an expression" = "Esta condición se edita como expresión"
|
||||
"This file" = "Este archivo"
|
||||
"This file has unsaved changes" = "Este archivo tiene cambios sin guardar"
|
||||
|
||||
@@ -1080,7 +1080,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "Versio {v} julkaistu palvelimelle"
|
||||
"Published version {v}: save the file so other files can use it" = "Versio {v} julkaistu: tallenna tiedosto, jotta muut tiedostot voivat käyttää sitä"
|
||||
"Published · {people}" = "Julkaistu · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Palvelimelle julkaiseminen vaatii työpöytäsovelluksen. Selaimessa voit liittyä palvelimen tiedostoihin."
|
||||
"Publishing with the server's key." = "Julkaistaan palvelimen avaimella."
|
||||
"Publishing…" = "Julkaistaan…"
|
||||
"Push" = "Työntö"
|
||||
@@ -1278,7 +1277,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "Jaa: reaaliaikainen istunto, vienti"
|
||||
"Shared" = "Jaettu"
|
||||
"Shared: copy its code from the file's menu" = "Jaettu: kopioi koodi tiedoston valikosta"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Tiedoston jakaminen vaatii työpöytäsovelluksen. Tässä voit liittyä jaettuun tiedostoon."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Verkkojakaminen on pois käytöstä: asetuksissa ei ole palvelinta. Tässä voit liittyä jaettuun tiedostoon."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Jaetaan lähiverkossa (verkkojakaminen on pois käytöstä: asetuksissa ei ole palvelinta)."
|
||||
"Sharing on your network · {people}" = "Jaetaan lähiverkossasi · {people}"
|
||||
"Sharing online · {people}" = "Jaetaan verkossa · {people}"
|
||||
@@ -1443,6 +1442,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "Tämä haara on yhdistetty tai arkistoitu."
|
||||
"This computer only" = "Vain tämä tietokone"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Tämä tietokone muistaa omistajan tunnisteen, joten tiedostoa voi hallita aloitusnäkymän Verkossa-luettelosta. Kopioi tunniste hallitaksesi tiedostoa muualla. Se on tämän tiedoston salasana, eikä sitä näytetä uudelleen."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Tämä sivu säilyttää omistajan tunnisteen vain niin kauan kuin se on auki. Kopioi tunniste hallitaksesi tiedostoa myöhemmin. Se on tämän tiedoston salasana, eikä sitä näytetä uudelleen."
|
||||
"This condition is edited as an expression" = "Tätä ehtoa muokataan lausekkeena"
|
||||
"This file" = "Tämä tiedosto"
|
||||
"This file has unsaved changes" = "Tässä tiedostossa on tallentamattomia muutoksia"
|
||||
|
||||
@@ -1079,7 +1079,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "Version {v} publiée sur le serveur"
|
||||
"Published version {v}: save the file so other files can use it" = "Version {v} publiée : enregistrez le fichier pour que d’autres fichiers puissent l’utiliser"
|
||||
"Published · {people}" = "Publié · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publier sur un serveur nécessite l’application de bureau ; dans le navigateur, vous pouvez rejoindre les fichiers d’un serveur"
|
||||
"Publishing with the server's key." = "Publication avec la clé du serveur."
|
||||
"Publishing…" = "Publication…"
|
||||
"Push" = "Pousser"
|
||||
@@ -1277,7 +1276,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "Partager : session en direct, exportation"
|
||||
"Shared" = "Partagé"
|
||||
"Shared: copy its code from the file's menu" = "Partagé : copiez son code depuis le menu du fichier"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Le partage d’un fichier nécessite l’application de bureau ; ici, vous pouvez en rejoindre un."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Le partage en ligne est désactivé : aucun serveur n’est défini dans les Préférences. Ici, vous pouvez rejoindre un fichier partagé."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Partage sur le réseau local (le partage en ligne est désactivé : aucun serveur n’est défini dans les Préférences)."
|
||||
"Sharing on your network · {people}" = "Partage sur votre réseau · {people}"
|
||||
"Sharing online · {people}" = "Partage en ligne · {people}"
|
||||
@@ -1443,6 +1442,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "Cette branche a été fusionnée ou archivée."
|
||||
"This computer only" = "Cet ordinateur uniquement"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Cet ordinateur mémorise le jeton de propriétaire : le fichier se gère depuis la liste En ligne de l’écran d’accueil. Copiez-le pour gérer le fichier depuis un autre endroit : c’est un mot de passe pour ce fichier et il ne sera plus affiché."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Cette page ne garde le jeton de propriétaire que tant qu’elle est ouverte. Copiez-le pour gérer le fichier plus tard : c’est un mot de passe pour ce fichier et il ne sera plus affiché."
|
||||
"This condition is edited as an expression" = "Cette condition se modifie en tant qu’expression"
|
||||
"This file" = "Ce fichier"
|
||||
"This file has unsaved changes" = "Ce fichier contient des modifications non enregistrées"
|
||||
|
||||
@@ -1062,7 +1062,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "Versi {v} dipublikasikan ke server"
|
||||
"Published version {v}: save the file so other files can use it" = "Versi {v} dipublikasikan: simpan file agar file lain dapat menggunakannya"
|
||||
"Published · {people}" = "Dipublikasikan · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publikasi ke server memerlukan aplikasi desktop; di browser Anda dapat bergabung ke file server"
|
||||
"Publishing with the server's key." = "Memublikasikan dengan kunci server."
|
||||
"Publishing…" = "Memublikasikan…"
|
||||
"Push" = "Dorong"
|
||||
@@ -1255,7 +1254,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "Bagikan: sesi langsung, ekspor"
|
||||
"Shared" = "Dibagikan"
|
||||
"Shared: copy its code from the file's menu" = "Dibagikan: salin kodenya dari menu file"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Berbagi file memerlukan aplikasi desktop; di sini Anda hanya dapat bergabung."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Berbagi online nonaktif: tidak ada server yang diatur di Preferensi. Di sini Anda dapat bergabung ke file yang dibagikan."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Berbagi di jaringan lokal (berbagi online nonaktif: tidak ada server yang diatur di Preferensi)."
|
||||
"Sharing on your network · {people}" = "Berbagi di jaringan Anda · {people}"
|
||||
"Sharing online · {people}" = "Berbagi online · {people}"
|
||||
@@ -1416,6 +1415,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "Branch ini telah di-merge atau diarsipkan."
|
||||
"This computer only" = "Hanya komputer ini"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Komputer ini mengingat token pemilik, sehingga file dapat dikelola dari daftar Online di layar beranda. Salin token untuk mengelola file dari tempat lain: token ini adalah kata sandi untuk file ini dan tidak akan ditampilkan lagi."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Halaman ini hanya menyimpan token pemilik selama halaman terbuka. Salin token untuk mengelola file nanti: token ini adalah kata sandi untuk file ini dan tidak akan ditampilkan lagi."
|
||||
"This condition is edited as an expression" = "Kondisi ini diedit sebagai ekspresi"
|
||||
"This file" = "File ini"
|
||||
"This file has unsaved changes" = "File ini memiliki perubahan yang belum disimpan"
|
||||
|
||||
@@ -1043,7 +1043,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "Versione {v} pubblicata sul server"
|
||||
"Published version {v}: save the file so other files can use it" = "Versione {v} pubblicata: salva il file perché altri file possano usarla"
|
||||
"Published · {people}" = "Pubblicato · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Per pubblicare su un server serve l'app desktop; nel browser puoi unirti ai file di un server"
|
||||
"Publishing with the server's key." = "Pubblicazione con la chiave del server."
|
||||
"Publishing…" = "Pubblicazione…"
|
||||
"Push" = "Invia"
|
||||
@@ -1234,7 +1233,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "Condividi: sessione live, esportazione"
|
||||
"Shared" = "Condiviso"
|
||||
"Shared: copy its code from the file's menu" = "Condiviso: copia il suo codice dal menu del file"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Per condividere un file serve l'app desktop; qui puoi partecipare a uno."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "La condivisione online è disattivata: nelle Preferenze non è impostato alcun server. Qui puoi partecipare a un file condiviso."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Condivisione sulla rete locale (la condivisione online è disattivata: nelle Preferenze non è impostato alcun server)."
|
||||
"Sharing on your network · {people}" = "Condivisione sulla tua rete · {people}"
|
||||
"Sharing online · {people}" = "Condivisione online · {people}"
|
||||
@@ -1389,6 +1388,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "Questo ramo è stato unito o archiviato."
|
||||
"This computer only" = "Solo questo computer"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Questo computer ricorda il token del proprietario, quindi il file può essere gestito dall'elenco Online della schermata Home. Copialo per gestire il file da un altro posto: è una password per questo file e non verrà mostrato di nuovo."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Questa pagina conserva il token del proprietario solo finché resta aperta. Copialo per gestire il file in seguito: è una password per questo file e non verrà mostrato di nuovo."
|
||||
"This condition is edited as an expression" = "Questa condizione viene modificata come espressione"
|
||||
"This file" = "Questo file"
|
||||
"This file has unsaved changes" = "Questo file ha modifiche non salvate"
|
||||
|
||||
@@ -1067,7 +1067,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "バージョン {v} をサーバーに公開済み"
|
||||
"Published version {v}: save the file so other files can use it" = "バージョン {v} を公開済み:他のファイルで使えるようにファイルを保存"
|
||||
"Published · {people}" = "公開済み · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "サーバーへの公開にはデスクトップアプリが必要。ブラウザーではサーバーのファイルに参加できる"
|
||||
"Publishing with the server's key." = "サーバーのキーで公開。"
|
||||
"Publishing…" = "公開中…"
|
||||
"Push" = "プッシュ"
|
||||
@@ -1264,7 +1263,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "共有:ライブセッション、書き出し"
|
||||
"Shared" = "共有中"
|
||||
"Shared: copy its code from the file's menu" = "共有済み:コードはファイルのメニューからコピー"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "ファイルの共有にはデスクトップアプリが必要。ここでは参加のみ可能。"
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "オンライン共有はオフ:環境設定でサーバーが未設定。ここでは共有ファイルへの参加のみ可能。"
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "ローカルネットワークで共有(オンライン共有はオフ:環境設定でサーバーが未設定)。"
|
||||
"Sharing on your network · {people}" = "ネットワークで共有中 · {people}"
|
||||
"Sharing online · {people}" = "オンラインで共有中 · {people}"
|
||||
@@ -1426,6 +1425,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "このブランチはマージまたはアーカイブ済み。"
|
||||
"This computer only" = "このコンピューターのみ"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "このコンピューターが所有者トークンを記憶するため、ファイルはホーム画面の「オンライン」リストから管理できる。別の場所から管理する場合はコピーしておくこと:このファイルのパスワードにあたり、再表示されない。"
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "このページは開いている間だけ所有者トークンを保持する。後でファイルを管理するにはコピーしておくこと:このファイルのパスワードにあたり、再表示されない。"
|
||||
"This condition is edited as an expression" = "この条件は式として編集されている"
|
||||
"This file" = "このファイル"
|
||||
"This file has unsaved changes" = "このファイルには未保存の変更がある"
|
||||
|
||||
@@ -1088,7 +1088,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Published version {v} to the server" = "ვერსია {v} გამოქვეყნდა სერვერზე"
|
||||
"Published version {v}: save the file so other files can use it" = "გამოქვეყნდა ვერსია {v}: შეინახეთ ფაილი, რომ სხვა ფაილებმა შეძლონ მისი გამოყენება"
|
||||
"Published · {people}" = "გამოქვეყნებულია · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "სერვერზე გამოსაქვეყნებლად სამუშაო მაგიდის აპია საჭირო; ბრაუზერში შეგიძლიათ სერვერის ფაილებს შეუერთდეთ"
|
||||
"Publishing with the server's key." = "ქვეყნდება სერვერის გასაღებით."
|
||||
"Publishing…" = "გამოქვეყნება…"
|
||||
"Push" = "ბიძგი"
|
||||
@@ -1291,7 +1290,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Share: live session, export" = "გაზიარება: ცოცხალი სესია, ექსპორტირება"
|
||||
"Shared" = "გაზიარებულია"
|
||||
"Shared: copy its code from the file's menu" = "გაზიარებულია: მისი კოდი დააკოპირეთ ფაილის მენიუდან"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "ფაილის გასაზიარებლად სამუშაო მაგიდის აპია საჭირო; აქ შეგიძლიათ შეუერთდეთ."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "ონლაინ გაზიარება გამორთულია: პარამეტრებში სერვერი არ არის მითითებული. აქ შეგიძლიათ შეუერთდეთ გაზიარებულ ფაილს."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "ზიარდება ლოკალურ ქსელში (ონლაინ გაზიარება გამორთულია: პარამეტრებში სერვერი არ არის მითითებული)."
|
||||
"Sharing on your network · {people}" = "ზიარდება თქვენს ქსელში · {people}"
|
||||
"Sharing online · {people}" = "ზიარდება ონლაინ · {people}"
|
||||
@@ -1457,6 +1456,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"This branch was merged or archived." = "ეს განშტოება შერწყმულია ან დაარქივებულია."
|
||||
"This computer only" = "მხოლოდ ეს კომპიუტერი"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "ეს კომპიუტერი იმახსოვრებს მფლობელის ტოკენს, ამიტომ ფაილის მართვა საწყისი ეკრანის „ონლაინ“ სიიდან შეიძლება. ფაილის სხვა ადგილიდან სამართავად დააკოპირეთ ის: ეს ამ ფაილის პაროლია და აღარ გამოჩნდება."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "ეს გვერდი მფლობელის ტოკენს მხოლოდ მანამ ინახავს, სანამ ღიაა. ფაილის მოგვიანებით სამართავად დააკოპირეთ ის: ეს ამ ფაილის პაროლია და აღარ გამოჩნდება."
|
||||
"This condition is edited as an expression" = "ეს პირობა ექსპრესიად რედაქტირდება"
|
||||
"This file" = "ეს ფაილი"
|
||||
"This file has unsaved changes" = "ამ ფაილს შეუნახავი ცვლილებები აქვს"
|
||||
|
||||
@@ -1018,7 +1018,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "버전 {v}을(를) 서버에 게시함"
|
||||
"Published version {v}: save the file so other files can use it" = "버전 {v} 게시됨: 다른 파일에서 사용할 수 있도록 파일을 저장하세요"
|
||||
"Published · {people}" = "게시됨 · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "서버 게시에는 데스크톱 앱이 필요함. 브라우저에서는 서버의 파일에 참여할 수 있음"
|
||||
"Publishing with the server's key." = "서버 키로 게시함."
|
||||
"Publishing…" = "게시하는 중…"
|
||||
"Push" = "밀어내기"
|
||||
@@ -1209,7 +1208,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "공유: 라이브 세션, 내보내기"
|
||||
"Shared" = "공유 중"
|
||||
"Shared: copy its code from the file's menu" = "공유됨: 파일 메뉴에서 코드를 복사하세요"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "파일을 공유하려면 데스크톱 앱이 필요함. 여기서는 참여만 가능."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "온라인 공유가 꺼져 있음: 환경설정에 서버가 지정되지 않음. 여기서는 공유된 파일에 참여만 가능."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "로컬 네트워크에서 공유 중(온라인 공유가 꺼져 있음: 환경설정에 서버가 지정되지 않음)."
|
||||
"Sharing on your network · {people}" = "내 네트워크에서 공유 중 · {people}"
|
||||
"Sharing online · {people}" = "온라인으로 공유 중 · {people}"
|
||||
@@ -1361,6 +1360,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "이 브랜치는 병합 또는 보관되었음."
|
||||
"This computer only" = "이 컴퓨터만"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "이 컴퓨터가 소유자 토큰을 기억하므로 홈 화면의 온라인 목록에서 파일을 관리할 수 있음. 다른 곳에서 관리하려면 복사해 두세요: 이 파일의 비밀번호와 같으며 다시 표시되지 않음."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "이 페이지는 열려 있는 동안에만 소유자 토큰을 보관함. 나중에 파일을 관리하려면 복사해 두세요: 이 파일의 비밀번호와 같으며 다시 표시되지 않음."
|
||||
"This condition is edited as an expression" = "이 조건은 수식으로 편집됨"
|
||||
"This file" = "이 파일"
|
||||
"This file has unsaved changes" = "이 파일에 저장되지 않은 변경 사항이 있음"
|
||||
|
||||
@@ -1080,7 +1080,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "Versie {v} naar de server gepubliceerd"
|
||||
"Published version {v}: save the file so other files can use it" = "Gepubliceerde versie {v}: sla het bestand op zodat andere bestanden het kunnen gebruiken"
|
||||
"Published · {people}" = "Gepubliceerd · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publiceren naar een server vraagt om de desktop-app; in de browser kun je deelnemen aan de bestanden van een server"
|
||||
"Publishing with the server's key." = "Publiceren met de sleutel van de server."
|
||||
"Publishing…" = "Bezig met publiceren…"
|
||||
"Push" = "Pushen"
|
||||
@@ -1278,7 +1277,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "Delen: live-sessie, exporteren"
|
||||
"Shared" = "Gedeeld"
|
||||
"Shared: copy its code from the file's menu" = "Gedeeld: kopieer de code uit het menu van het bestand"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Een bestand delen vereist de desktop-app; hier kun je aan één deelnemen."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Online delen staat uit: er is geen server ingesteld in Voorkeuren. Hier kun je deelnemen aan een gedeeld bestand."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Delen op het lokale netwerk (online delen staat uit: er is geen server ingesteld in Voorkeuren)."
|
||||
"Sharing on your network · {people}" = "Delen via je netwerk · {people}"
|
||||
"Sharing online · {people}" = "Delen online · {people}"
|
||||
@@ -1443,6 +1442,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "Deze branch is samengevoegd of gearchiveerd."
|
||||
"This computer only" = "Alleen op deze computer"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Deze computer onthoudt het eigenaartoken, zodat het bestand beheerd kan worden vanuit de Online-lijst op het beginscherm. Kopieer het om het bestand vanaf een andere plek te beheren: het is het wachtwoord voor dit bestand en wordt niet opnieuw weergegeven."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Deze pagina bewaart het eigenaartoken alleen zolang ze open is. Kopieer het om het bestand later te beheren: het is het wachtwoord voor dit bestand en wordt niet opnieuw weergegeven."
|
||||
"This condition is edited as an expression" = "Deze voorwaarde wordt als expressie bewerkt"
|
||||
"This file" = "Dit bestand"
|
||||
"This file has unsaved changes" = "Dit bestand heeft niet-opgeslagen wijzigingen"
|
||||
|
||||
@@ -1082,7 +1082,6 @@ name = "Polski"
|
||||
"Published version {v} to the server" = "Opublikowano wersję {v} na serwerze"
|
||||
"Published version {v}: save the file so other files can use it" = "Opublikowano wersję {v}: zapisz plik, aby inne pliki mogły go używać"
|
||||
"Published · {people}" = "Opublikowano · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publikowanie na serwerze wymaga aplikacji desktopowej; w przeglądarce możesz dołączać do plików serwera"
|
||||
"Publishing with the server's key." = "Publikowanie przy użyciu klucza serwera."
|
||||
"Publishing…" = "Publikowanie…"
|
||||
"Push" = "Wypychanie"
|
||||
@@ -1284,7 +1283,7 @@ name = "Polski"
|
||||
"Share: live session, export" = "Udostępnianie: sesja na żywo, eksport"
|
||||
"Shared" = "Udostępniono"
|
||||
"Shared: copy its code from the file's menu" = "Udostępniono: skopiuj kod z menu pliku"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Udostępnianie pliku wymaga aplikacji desktopowej; tutaj możesz tylko dołączyć."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Udostępnianie online wyłączone: brak serwera w Preferencjach. Tutaj możesz dołączyć do udostępnionego pliku."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Udostępnianie w sieci lokalnej (udostępnianie online wyłączone: brak serwera w Preferencjach)."
|
||||
"Sharing on your network · {people}" = "Udostępnianie w Twojej sieci · {people}"
|
||||
"Sharing online · {people}" = "Udostępnianie online · {people}"
|
||||
@@ -1450,6 +1449,7 @@ name = "Polski"
|
||||
"This branch was merged or archived." = "Ta gałąź została scalona lub zarchiwizowana."
|
||||
"This computer only" = "Tylko ten komputer"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Ten komputer pamięta token właściciela, więc plik może być zarządzany z listy Online na ekranie głównym. Skopiuj go, aby zarządzać plikiem z innego miejsca: jest to hasło do tego pliku i nie zostanie ponownie wyświetlone."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Ta strona przechowuje token właściciela tylko, dopóki jest otwarta. Skopiuj go, aby zarządzać plikiem później: jest to hasło do tego pliku i nie zostanie ponownie wyświetlone."
|
||||
"This condition is edited as an expression" = "Ten warunek jest edytowany jako wyrażenie"
|
||||
"This file" = "Ten plik"
|
||||
"This file has unsaved changes" = "Ten plik ma niezapisane zmiany"
|
||||
|
||||
@@ -1017,7 +1017,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "Versão {v} publicada no servidor"
|
||||
"Published version {v}: save the file so other files can use it" = "Versão {v} publicada: salve o arquivo para que outros arquivos possam usá-la"
|
||||
"Published · {people}" = "Publicado · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Para publicar em um servidor é preciso o app para desktop; no navegador você pode entrar em arquivos de um servidor"
|
||||
"Publishing with the server's key." = "Publicando com a chave do servidor."
|
||||
"Publishing…" = "Publicando…"
|
||||
"Push" = "Empurrar"
|
||||
@@ -1208,7 +1207,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "Compartilhar: sessão ao vivo, exportar"
|
||||
"Shared" = "Compartilhado"
|
||||
"Shared: copy its code from the file's menu" = "Compartilhado: copie o código pelo menu do arquivo"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Compartilhar um arquivo requer o aplicativo para desktop; aqui você pode entrar em um."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "O compartilhamento online está desativado: não há servidor definido nas Preferências. Aqui você pode entrar em um arquivo compartilhado."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Compartilhando na rede local (o compartilhamento online está desativado: não há servidor definido nas Preferências)."
|
||||
"Sharing on your network · {people}" = "Compartilhando na sua rede · {people}"
|
||||
"Sharing online · {people}" = "Compartilhando online · {people}"
|
||||
@@ -1359,6 +1358,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "Esta branch foi mesclada ou arquivada."
|
||||
"This computer only" = "Somente este computador"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Este computador guarda o token de proprietário, então o arquivo pode ser gerenciado pela lista Online da tela inicial. Copie-o para gerenciar o arquivo de outro lugar: ele é uma senha para este arquivo e não será mostrado de novo."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Esta página guarda o token de proprietário só enquanto estiver aberta. Copie-o para gerenciar o arquivo depois: ele é uma senha para este arquivo e não será mostrado de novo."
|
||||
"This condition is edited as an expression" = "Esta condição é editada como expressão"
|
||||
"This file" = "Este arquivo"
|
||||
"This file has unsaved changes" = "Este arquivo tem alterações não salvas"
|
||||
|
||||
@@ -1082,7 +1082,6 @@ name = "Русский"
|
||||
"Published version {v} to the server" = "Версия {v} опубликована на сервере"
|
||||
"Published version {v}: save the file so other files can use it" = "Опубликована версия {v}: сохраните файл, чтобы её можно было использовать в других файлах"
|
||||
"Published · {people}" = "Опубликовано · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Для публикации на сервере нужно настольное приложение; в браузере можно подключаться к файлам сервера"
|
||||
"Publishing with the server's key." = "Публикация с ключом сервера."
|
||||
"Publishing…" = "Публикация…"
|
||||
"Push" = "Вытеснение"
|
||||
@@ -1284,7 +1283,7 @@ name = "Русский"
|
||||
"Share: live session, export" = "Поделиться: совместная сессия, экспорт"
|
||||
"Shared" = "Опубликовано"
|
||||
"Shared: copy its code from the file's menu" = "Опубликовано: скопируйте код в меню файла"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Для публикации файла нужно настольное приложение; здесь можно подключиться к опубликованному файлу."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Онлайн-публикация отключена: сервер не задан в настройках. Здесь можно подключиться к опубликованному файлу."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Общий доступ в локальной сети (онлайн-публикация отключена: сервер не задан в настройках)."
|
||||
"Sharing on your network · {people}" = "Общий доступ в вашей сети · {people}"
|
||||
"Sharing online · {people}" = "Общий доступ онлайн · {people}"
|
||||
@@ -1450,6 +1449,7 @@ name = "Русский"
|
||||
"This branch was merged or archived." = "Ветка объединена или архивирована."
|
||||
"This computer only" = "Только этот компьютер"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Этот компьютер сохраняет токен владельца для управления файлом из списка «Онлайн» на главной. Скопируйте токен для управления с другого устройства: это пароль к файлу, он больше не будет показан."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Эта страница хранит токен владельца, только пока она открыта. Скопируйте токен, чтобы управлять файлом позже: это пароль к файлу, он больше не будет показан."
|
||||
"This condition is edited as an expression" = "Это условие редактируется как выражение"
|
||||
"This file" = "Этот файл"
|
||||
"This file has unsaved changes" = "В файле есть несохранённые изменения"
|
||||
|
||||
@@ -1108,7 +1108,6 @@ name = "Svenska"
|
||||
"Published version {v} to the server" = "Version {v} publicerad på servern"
|
||||
"Published version {v}: save the file so other files can use it" = "Version {v} publicerad: spara filen så att andra filer kan använda den"
|
||||
"Published · {people}" = "Publicerad · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publicering på en server kräver skrivbordsappen; i webbläsaren kan du ansluta till en servers filer"
|
||||
"Publishing with the server's key." = "Publicerar med serverns nyckel."
|
||||
"Publishing…" = "Publicerar…"
|
||||
"Push" = "Skjut"
|
||||
@@ -1313,7 +1312,7 @@ name = "Svenska"
|
||||
"Share: live session, export" = "Dela: livesession, export"
|
||||
"Shared" = "Delad"
|
||||
"Shared: copy its code from the file's menu" = "Delad: kopiera koden från filens meny"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Att dela en fil kräver skrivbordsappen; här kan du ansluta till en."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Delning online är av: ingen server har angetts i Inställningar. Här kan du ansluta till en delad fil."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Delar i det lokala nätverket (delning online är av: ingen server har angetts i Inställningar)."
|
||||
"Sharing on your network · {people}" = "Delar i ditt nätverk · {people}"
|
||||
"Sharing online · {people}" = "Delar online · {people}"
|
||||
@@ -1480,6 +1479,7 @@ name = "Svenska"
|
||||
"This branch was merged or archived." = "Den här grenen har sammanfogats eller arkiverats."
|
||||
"This computer only" = "Endast den här datorn"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Den här datorn kommer ihåg ägartoken, så filen kan hanteras från startskärmens onlinelista. Kopiera den om du vill hantera filen från en annan plats: den fungerar som ett lösenord för filen och visas inte igen."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Den här sidan behåller ägartoken bara så länge den är öppen. Kopiera den om du vill hantera filen senare: den fungerar som ett lösenord för filen och visas inte igen."
|
||||
"This condition is edited as an expression" = "Det här villkoret redigeras som ett uttryck"
|
||||
"This file" = "Den här filen"
|
||||
"This file has unsaved changes" = "Filen har osparade ändringar"
|
||||
|
||||
@@ -1073,7 +1073,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "เผยแพร่เวอร์ชัน {v} ไปยังเซิร์ฟเวอร์แล้ว"
|
||||
"Published version {v}: save the file so other files can use it" = "เผยแพร่เวอร์ชัน {v} แล้ว: บันทึกไฟล์เพื่อให้ไฟล์อื่นใช้ได้"
|
||||
"Published · {people}" = "เผยแพร่แล้ว · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "การเผยแพร่ไปยังเซิร์ฟเวอร์ต้องใช้แอปเดสก์ท็อป ในเบราว์เซอร์เข้าร่วมไฟล์ของเซิร์ฟเวอร์ได้"
|
||||
"Publishing with the server's key." = "กำลังเผยแพร่ด้วยคีย์ของเซิร์ฟเวอร์"
|
||||
"Publishing…" = "กำลังเผยแพร่…"
|
||||
"Push" = "ผลัก"
|
||||
@@ -1269,7 +1268,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "แชร์: เซสชันสด ส่งออก"
|
||||
"Shared" = "แชร์แล้ว"
|
||||
"Shared: copy its code from the file's menu" = "แชร์แล้ว: คัดลอกรหัสจากเมนูของไฟล์"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "การแชร์ไฟล์ต้องใช้แอปเดสก์ท็อป ที่นี่เข้าร่วมได้อย่างเดียว"
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "การแชร์ออนไลน์ปิดอยู่: ไม่ได้ตั้งเซิร์ฟเวอร์ในการตั้งค่า ที่นี่เข้าร่วมไฟล์ที่แชร์ได้อย่างเดียว"
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "กำลังแชร์ในเครือข่ายภายใน (การแชร์ออนไลน์ปิดอยู่: ไม่ได้ตั้งเซิร์ฟเวอร์ในการตั้งค่า)"
|
||||
"Sharing on your network · {people}" = "กำลังแชร์ในเครือข่ายของคุณ · {people}"
|
||||
"Sharing online · {people}" = "กำลังแชร์ออนไลน์ · {people}"
|
||||
@@ -1432,6 +1431,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "แบรนช์นี้ถูกรวมหรือเก็บถาวรแล้ว"
|
||||
"This computer only" = "เฉพาะเครื่องนี้"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "คอมพิวเตอร์เครื่องนี้จำโทเคนเจ้าของไว้ จึงจัดการไฟล์ได้จากรายการออนไลน์ในหน้าจอหลัก คัดลอกไว้เพื่อจัดการไฟล์จากที่อื่น: โทเคนนี้เป็นรหัสผ่านของไฟล์นี้และจะไม่แสดงอีก"
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "หน้านี้เก็บโทเคนเจ้าของไว้เฉพาะตอนที่เปิดอยู่ คัดลอกไว้เพื่อจัดการไฟล์ภายหลัง: โทเคนนี้เป็นรหัสผ่านของไฟล์นี้และจะไม่แสดงอีก"
|
||||
"This condition is edited as an expression" = "เงื่อนไขนี้แก้ไขเป็นนิพจน์"
|
||||
"This file" = "ไฟล์นี้"
|
||||
"This file has unsaved changes" = "ไฟล์นี้มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก"
|
||||
|
||||
@@ -1059,7 +1059,6 @@ name = "Türkçe"
|
||||
"Published version {v} to the server" = "{v} sürümü sunucuya yayımlandı"
|
||||
"Published version {v}: save the file so other files can use it" = "{v} sürümü yayımlandı: diğer dosyaların kullanabilmesi için dosyayı kaydedin"
|
||||
"Published · {people}" = "Yayımlandı · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Bir sunucuya yayımlamak için masaüstü uygulaması gerekir; tarayıcıda bir sunucunun dosyalarına katılabilirsiniz"
|
||||
"Publishing with the server's key." = "Sunucunun anahtarıyla yayımlanıyor."
|
||||
"Publishing…" = "Yayımlanıyor…"
|
||||
"Push" = "İt"
|
||||
@@ -1253,7 +1252,7 @@ name = "Türkçe"
|
||||
"Share: live session, export" = "Paylaş: canlı oturum, dışa aktarma"
|
||||
"Shared" = "Paylaşıldı"
|
||||
"Shared: copy its code from the file's menu" = "Paylaşıldı: kodunu dosyanın menüsünden kopyalayın"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Bir dosyayı paylaşmak için masaüstü uygulaması gerekir; burada birine katılabilirsiniz."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Çevrimiçi paylaşım kapalı: Tercihler'de sunucu ayarlanmamış. Burada paylaşılan bir dosyaya katılabilirsiniz."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Yerel ağda paylaşılıyor (çevrimiçi paylaşım kapalı: Tercihler'de sunucu ayarlanmamış)."
|
||||
"Sharing on your network · {people}" = "Ağınızda paylaşılıyor · {people}"
|
||||
"Sharing online · {people}" = "Çevrimiçi paylaşılıyor · {people}"
|
||||
@@ -1414,6 +1413,7 @@ name = "Türkçe"
|
||||
"This branch was merged or archived." = "Bu dal birleştirildi veya arşivlendi."
|
||||
"This computer only" = "Yalnızca bu bilgisayar"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Bu bilgisayar sahip belirtecini hatırlar, böylece dosya ana ekrandaki Çevrimiçi listesinden yönetilebilir. Dosyayı başka bir yerden yönetmek için kopyalayın: bu, dosyanın parolasıdır ve bir daha gösterilmez."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Bu sayfa sahip belirtecini yalnızca açık kaldığı sürece tutar. Dosyayı daha sonra yönetmek için kopyalayın: bu, dosyanın parolasıdır ve bir daha gösterilmez."
|
||||
"This condition is edited as an expression" = "Bu koşul bir ifade olarak düzenleniyor"
|
||||
"This file" = "Bu dosya"
|
||||
"This file has unsaved changes" = "Bu dosyada kaydedilmemiş değişiklikler var"
|
||||
|
||||
@@ -1044,7 +1044,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "Đã xuất bản phiên bản {v} lên máy chủ"
|
||||
"Published version {v}: save the file so other files can use it" = "Đã xuất bản phiên bản {v}: hãy lưu tệp để các tệp khác có thể sử dụng"
|
||||
"Published · {people}" = "Đã xuất bản · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Cần ứng dụng máy tính để xuất bản lên máy chủ; trên trình duyệt bạn có thể tham gia các tệp trên máy chủ"
|
||||
"Publishing with the server's key." = "Đang xuất bản bằng khóa của máy chủ."
|
||||
"Publishing…" = "Đang xuất bản…"
|
||||
"Push" = "Đẩy"
|
||||
@@ -1235,7 +1234,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "Chia sẻ: phiên trực tiếp, xuất tệp"
|
||||
"Shared" = "Đã chia sẻ"
|
||||
"Shared: copy its code from the file's menu" = "Đã chia sẻ: sao chép mã từ trình đơn của tệp"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "Cần ứng dụng máy tính để chia sẻ tệp; tại đây bạn có thể tham gia một phiên."
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "Chia sẻ trực tuyến đang tắt: chưa đặt máy chủ trong Tùy chọn. Tại đây bạn có thể tham gia một tệp được chia sẻ."
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Đang chia sẻ trên mạng cục bộ (chia sẻ trực tuyến đang tắt: chưa đặt máy chủ trong Tùy chọn)."
|
||||
"Sharing on your network · {people}" = "Đang chia sẻ trên mạng của bạn · {people}"
|
||||
"Sharing online · {people}" = "Đang chia sẻ trực tuyến · {people}"
|
||||
@@ -1390,6 +1389,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "Nhánh này đã được hợp nhất hoặc lưu trữ."
|
||||
"This computer only" = "Chỉ máy tính này"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Máy tính này ghi nhớ mã thông báo chủ sở hữu để bạn có thể quản lý tệp từ danh sách Trực tuyến ở màn hình Trang chủ. Hãy sao chép mã này nếu muốn quản lý tệp từ nơi khác: đây là mật khẩu của tệp và sẽ không hiển thị lại."
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Trang này chỉ giữ mã thông báo chủ sở hữu khi còn mở. Hãy sao chép mã này để quản lý tệp sau: đây là mật khẩu của tệp và sẽ không hiển thị lại."
|
||||
"This condition is edited as an expression" = "Điều kiện này được chỉnh sửa dưới dạng biểu thức"
|
||||
"This file" = "Tệp này"
|
||||
"This file has unsaved changes" = "Tệp này có thay đổi chưa lưu"
|
||||
|
||||
@@ -1026,7 +1026,6 @@ status = "draft — needs native review"
|
||||
"Published version {v} to the server" = "已将版本 {v} 发布到服务器"
|
||||
"Published version {v}: save the file so other files can use it" = "已发布版本 {v}:请保存文件,以便其他文件使用"
|
||||
"Published · {people}" = "已发布 · {people}"
|
||||
"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "发布到服务器需要桌面应用;在浏览器中可以加入服务器上的文件"
|
||||
"Publishing with the server's key." = "使用服务器密钥发布。"
|
||||
"Publishing…" = "正在发布…"
|
||||
"Push" = "推入"
|
||||
@@ -1217,7 +1216,7 @@ status = "draft — needs native review"
|
||||
"Share: live session, export" = "分享:实时会话、导出"
|
||||
"Shared" = "已分享"
|
||||
"Shared: copy its code from the file's menu" = "已分享:可在文件的菜单中复制代码"
|
||||
"Sharing a file needs the desktop app; here you can join one." = "分享文件需要桌面应用;在这里你可以加入他人分享的文件。"
|
||||
"Online sharing is off: no server is set in Preferences. Here you can join a shared file." = "在线分享已关闭:偏好设置中未指定服务器。在这里你可以加入他人分享的文件。"
|
||||
"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "正在本地网络中分享(在线分享已关闭:偏好设置中未指定服务器)。"
|
||||
"Sharing on your network · {people}" = "正在你的网络中分享 · {people}"
|
||||
"Sharing online · {people}" = "正在在线分享 · {people}"
|
||||
@@ -1369,6 +1368,7 @@ status = "draft — needs native review"
|
||||
"This branch was merged or archived." = "此分支已合并或已归档。"
|
||||
"This computer only" = "仅限这台电脑"
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "这台电脑会记住所有者令牌,因此可以在主屏幕的“在线”列表中管理此文件。如需在别处管理此文件,请复制它:它相当于此文件的密码,且不会再次显示。"
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "此页面仅在打开期间保留所有者令牌。如需稍后管理此文件,请复制它:它相当于此文件的密码,且不会再次显示。"
|
||||
"This condition is edited as an expression" = "此条件以表达式编辑"
|
||||
"This file" = "此文件"
|
||||
"This file has unsaved changes" = "此文件有未保存的更改"
|
||||
|
||||
+70
-55
@@ -51,6 +51,11 @@ const OWNER_NOTE: &str = tr_noop(
|
||||
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again.",
|
||||
);
|
||||
|
||||
/// The same in the browser, which remembers nothing once the page closes.
|
||||
const BROWSER_OWNER_NOTE: &str = tr_noop(
|
||||
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again.",
|
||||
);
|
||||
|
||||
/// What a server open to the public can see.
|
||||
const PLAIN_NOTE: &str = tr_noop("Files aren't end-to-end encrypted: the server's operator can read them.");
|
||||
|
||||
@@ -817,22 +822,24 @@ pub(crate) fn failure_text(reason: &str) -> Cow<'_, str> {
|
||||
}
|
||||
|
||||
/// Not in a session: your name, how to share (online or on the local network), start; or join
|
||||
/// one with a code. Everything else is under Advanced.
|
||||
/// one with a code. Everything else is under Advanced. The browser shares online only (a page
|
||||
/// can't host a session).
|
||||
fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
name_row(app, ui, t, egui::Id::unique("live_name"));
|
||||
if !app.session.live.can_host() {
|
||||
widgets::label(ui, tr("Sharing a file needs the desktop app; here you can join one."), t.text_secondary);
|
||||
let server = app.session.prefs.publish_server.trim().to_string();
|
||||
let hosts = app.session.live.can_host();
|
||||
if !hosts && server.is_empty() {
|
||||
widgets::label(ui, tr("Online sharing is off: no server is set in Preferences. Here you can join a shared file."), t.text_secondary);
|
||||
widgets::label(ui, tr("Join with a code"), t.text);
|
||||
join_row(app, ui, JOIN_WIDTH);
|
||||
widgets::label(ui, tr(BROWSER_NOTE), t.text_secondary);
|
||||
return;
|
||||
}
|
||||
// Without a server in Preferences only the local network is left.
|
||||
let server = app.session.prefs.publish_server.trim().to_string();
|
||||
let local = app.ui.live.local || server.is_empty();
|
||||
let local = hosts && (app.ui.live.local || server.is_empty());
|
||||
if server.is_empty() {
|
||||
widgets::label(ui, tr("Sharing on the local network (online sharing is off: no server is set in Preferences)."), t.text_secondary);
|
||||
} else if let Some(i) = widgets::segmented_text(ui, &[tr("Online"), tr("Local network")], usize::from(local)) {
|
||||
} else if hosts && let Some(i) = widgets::segmented_text(ui, &[tr("Online"), tr("Local network")], usize::from(local)) {
|
||||
app.ui.live.local = i == 1;
|
||||
}
|
||||
if local {
|
||||
@@ -841,54 +848,64 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
online_note(app, ui, t, &server);
|
||||
}
|
||||
if widgets::button(ui, tr("Start sharing"), true, true).clicked() {
|
||||
let mut params = json!({"via": if local { "local" } else { "online" }});
|
||||
if local && app.ui.live.this_computer {
|
||||
params["bind"] = json!("local");
|
||||
}
|
||||
if let (true, Ok(port)) = (local, app.ui.live.port.trim().parse::<u16>()) {
|
||||
params["port"] = json!(port);
|
||||
}
|
||||
// The key goes out only to the server it belongs to (the server in Preferences): changing
|
||||
// the server must not send the old server's key to a new one.
|
||||
if !local
|
||||
&& let Some((at, key)) = app.ui.live.share_key.as_ref()
|
||||
&& *at == server
|
||||
&& !key.is_empty()
|
||||
{
|
||||
params["key"] = json!(key);
|
||||
}
|
||||
app.run("collab.share", params);
|
||||
start_sharing(app, local, &server);
|
||||
}
|
||||
join_and_advanced(app, ui, t, |app, ui, t| {
|
||||
if local {
|
||||
if widgets::checkbox(ui, app.ui.live.this_computer, tr("This computer only")) {
|
||||
app.ui.live.this_computer = !app.ui.live.this_computer;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
widgets::label(ui, tr("Port"), t.text);
|
||||
if let Some(port) = widgets::text_field_named(ui, egui::Id::unique("live_port"), &app.ui.live.port, 70.0, tr("Port")) {
|
||||
// Digits only; empty: the usual port, or any free one.
|
||||
app.ui.live.port = port.trim().parse::<u16>().map(|p| p.to_string()).unwrap_or_default();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
widgets::label(ui, tr("Another server, or one that needs its key. The usual server is set in Preferences."), t.text_secondary);
|
||||
// The server key (a server that needs one; the address in Preferences is without it).
|
||||
// It is remembered for the server it was typed for, so it never leaks to another one.
|
||||
ui.horizontal(|ui| {
|
||||
widgets::label(ui, tr("Server key"), t.text);
|
||||
let shown = app.ui.live.share_key.as_ref().and_then(|(at, key)| (*at == server).then_some(key.as_str())).unwrap_or_default();
|
||||
if let Some(key) = widgets::secret_field_named(ui, egui::Id::unique("live_share_key"), shown, 200.0, tr("Server key")) {
|
||||
let key: String = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect();
|
||||
app.ui.live.share_key = if key.is_empty() { None } else { Some((server.trim().to_string(), key)) };
|
||||
}
|
||||
});
|
||||
if widgets::button(ui, tr("Publish to another server…"), false, true).clicked() {
|
||||
open_publish(app);
|
||||
ui.close();
|
||||
join_and_advanced(app, ui, t, |app, ui, t| share_advanced(app, ui, t, local, &server));
|
||||
}
|
||||
|
||||
/// Start sharing the open file on the local network, or online on `server` (the one in
|
||||
/// Preferences).
|
||||
fn start_sharing(app: &mut FigApp, local: bool, server: &str) {
|
||||
let mut params = json!({"via": if local { "local" } else { "online" }});
|
||||
if local && app.ui.live.this_computer {
|
||||
params["bind"] = json!("local");
|
||||
}
|
||||
if let (true, Ok(port)) = (local, app.ui.live.port.trim().parse::<u16>()) {
|
||||
params["port"] = json!(port);
|
||||
}
|
||||
// The key goes out only to the server it belongs to (the server in Preferences): changing
|
||||
// the server must not send the old server's key to a new one.
|
||||
if !local
|
||||
&& let Some((at, key)) = app.ui.live.share_key.as_ref()
|
||||
&& at == server
|
||||
&& !key.is_empty()
|
||||
{
|
||||
params["key"] = json!(key);
|
||||
}
|
||||
app.run("collab.share", params);
|
||||
}
|
||||
|
||||
/// The Advanced part of starting a share: where on this computer (the local network), or the
|
||||
/// server's key and another server (online).
|
||||
fn share_advanced(app: &mut FigApp, ui: &mut Ui, t: &Tokens, local: bool, server: &str) {
|
||||
if local {
|
||||
if widgets::checkbox(ui, app.ui.live.this_computer, tr("This computer only")) {
|
||||
app.ui.live.this_computer = !app.ui.live.this_computer;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
widgets::label(ui, tr("Port"), t.text);
|
||||
if let Some(port) = widgets::text_field_named(ui, egui::Id::unique("live_port"), &app.ui.live.port, 70.0, tr("Port")) {
|
||||
// Digits only; empty: the usual port, or any free one.
|
||||
app.ui.live.port = port.trim().parse::<u16>().map(|p| p.to_string()).unwrap_or_default();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
widgets::label(ui, tr("Another server, or one that needs its key. The usual server is set in Preferences."), t.text_secondary);
|
||||
// The server key (a server that needs one; the address in Preferences is without it).
|
||||
// It is remembered for the server it was typed for, so it never leaks to another one.
|
||||
ui.horizontal(|ui| {
|
||||
widgets::label(ui, tr("Server key"), t.text);
|
||||
let shown = app.ui.live.share_key.as_ref().and_then(|(at, key)| (at == server).then_some(key.as_str())).unwrap_or_default();
|
||||
if let Some(key) = widgets::secret_field_named(ui, egui::Id::unique("live_share_key"), shown, 200.0, tr("Server key")) {
|
||||
let key: String = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect();
|
||||
app.ui.live.share_key = if key.is_empty() { None } else { Some((server.trim().to_string(), key)) };
|
||||
}
|
||||
});
|
||||
if widgets::button(ui, tr("Publish to another server…"), false, true).clicked() {
|
||||
open_publish(app);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// "Your name" and its field (the Share popover, the Join dialog).
|
||||
@@ -1256,10 +1273,6 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) {
|
||||
/// Open the Publish to server dialog for the open file, with the last address used this run in
|
||||
/// it (the server in Preferences is what Share ▸ Online uses: this dialog is for another one).
|
||||
pub fn open_publish(app: &mut FigApp) {
|
||||
if !app.session.live.can_host() {
|
||||
app.toast(tr("Publishing to a server needs the desktop app; in the browser you can join a server's files"));
|
||||
return;
|
||||
}
|
||||
let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default();
|
||||
app.ui.live.publish = Some(PublishForm { server: app.ui.live.last_server.clone(), name, info: None });
|
||||
app.ui.live.focus_publish = true;
|
||||
@@ -1397,12 +1410,14 @@ fn published_dialog(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get(ctx);
|
||||
let mut open = true;
|
||||
let mut done = false;
|
||||
// The browser keeps only your name across reloads, so nothing remembers the token there.
|
||||
let note = if app.session.live.can_host() { OWNER_NOTE } else { BROWSER_OWNER_NOTE };
|
||||
crate::dialogs::window(tr("File published"), ctx).id(egui::Id::unique("File published")).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(400.0);
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
widgets::label(ui, &trf("{name} is on the server.", &[("name", &p.name)]), t.text);
|
||||
link_rows(app, ui, &[(tr("Owner token"), &owner, tr("Owner token copied"))]);
|
||||
widgets::label(ui, tr(OWNER_NOTE), t.danger);
|
||||
widgets::label(ui, tr(note), t.danger);
|
||||
link_rows(app, ui, &[(tr("Edit link"), &p.edit, tr("Edit link copied")), (tr("View link"), &p.view, tr("View link copied"))]);
|
||||
expiry_line(ui, &t, p.expires);
|
||||
if let Some(h) = &hosting {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! (it joins but can't host).
|
||||
|
||||
use egui::{Event, Key};
|
||||
use figcraft_engine::live::{Event as LiveEvent, HostOptions, LiveLink, Presence, SessionInfo, Status};
|
||||
use figcraft_engine::live::{Event as LiveEvent, HostOptions, LiveLink, Presence, PublishOptions, SessionInfo, Status};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::live_tests::{Harness, has_text, text_center};
|
||||
@@ -58,7 +58,8 @@ fn opening_the_app_with_an_invite_link_asks_before_joining() {
|
||||
assert_eq!(h.app.session.live().status, Status::Off, "nothing is joined until the person confirms");
|
||||
}
|
||||
|
||||
/// The browser's link: joins, can't host; joining fails here to show the dialog path.
|
||||
/// The browser's link: joins and publishes, can't host; joining fails here to show the dialog
|
||||
/// path.
|
||||
struct Browser;
|
||||
|
||||
impl LiveLink for Browser {
|
||||
@@ -71,6 +72,9 @@ impl LiveLink for Browser {
|
||||
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
|
||||
Err("that's not a live session invite".into())
|
||||
}
|
||||
fn publish(&mut self, _: u64, _: PublishOptions) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, _: u64) {}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
|
||||
@@ -91,20 +95,33 @@ fn a_link_that_cant_be_used_opens_the_dialog_with_it() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_joins_but_does_not_host() {
|
||||
fn the_browser_shares_through_a_server_but_does_not_host() {
|
||||
let (mut h, _) = Harness::new();
|
||||
h.app.session.live.install(Box::new(Browser));
|
||||
assert!(!h.app.session.live.can_host());
|
||||
assert_eq!(h.app.session.execute("collab.peers", &json!({})).unwrap()["canHost"], false);
|
||||
// Without a server there is nothing to share through: the join field is right there, with
|
||||
// what the browser can reach.
|
||||
h.app.session.execute("pref.publishServer", &json!({"url": ""})).unwrap();
|
||||
let shapes = h.frames(3);
|
||||
h.click(text_center(&shapes, "Share"));
|
||||
let shapes = h.frames(2);
|
||||
assert!(has_text(&shapes, "Sharing a file needs the desktop app; here you can join one."));
|
||||
assert!(has_text(&shapes, "Online sharing is off: no server is set in Preferences. Here you can join a shared file."));
|
||||
assert!(!has_text(&shapes, "Start sharing") && !has_text(&shapes, "Local network"));
|
||||
// The join field is right there, with what the browser can reach.
|
||||
assert!(has_text(&shapes, "Join with a code") && has_text(&shapes, "Join"));
|
||||
assert!(has_text(
|
||||
&shapes,
|
||||
"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)."
|
||||
));
|
||||
// With one, it shares online only (the local network needs a page that can listen).
|
||||
h.app.session.execute("pref.publishServer", &json!({"url": "ws://127.0.0.1:47910/"})).unwrap();
|
||||
let shapes = h.frames(2);
|
||||
assert!(!has_text(&shapes, "Local network") && !has_text(&shapes, "Online"));
|
||||
h.click(text_center(&shapes, "Start sharing"));
|
||||
h.frames(1);
|
||||
let live = h.app.session.live();
|
||||
assert_eq!((live.status.clone(), live.server.as_str()), (Status::Connecting, "ws://127.0.0.1:47910/"), "publishing to the server");
|
||||
// Publish to server… opens its dialog here too.
|
||||
crate::menus::invoke(&mut h.app, "collab.publish");
|
||||
assert!(h.app.ui.live.publish.is_some());
|
||||
}
|
||||
@@ -8,7 +8,7 @@ embeds, invite links, document sync end to end (several apps edit one file), liv
|
||||
selections, avatars and follow. **Step 3 is implemented**: per-user undo and redo, rejection
|
||||
handling and offline queueing with reconnect (`crates/collab`, wired into the engine's live
|
||||
session and the transport). **Step 4 is implemented**: the browser build joins sessions a desktop
|
||||
app hosts, and `figcraft://` invite links open the app and join. Styles, variables, collections,
|
||||
app hosts and publishes files to a server, and `figcraft://` invite links open the app and join. Styles, variables, collections,
|
||||
modes and comments created in a session take ids from their creator's id space, and others' text
|
||||
carets, cursor chat and spotlights show live (see Presence). **Step 5 is implemented**: the
|
||||
standalone `figcraft-server` (`apps/figcraft-server`, running it: [`server.md`](server.md)) keeps
|
||||
@@ -532,7 +532,8 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
is final; a first join that can't reach the host fails at once. A reconnect resumes as the same
|
||||
client: the guest's connection keeps the session id, client id and last sequence number from
|
||||
the messages it read and sends them in its next `Hello` (see Offline and reconnect).
|
||||
- The browser build joins (see *Built (the browser client)*); hosting needs the desktop app.
|
||||
- The browser build joins and publishes to a server (see *Built (the browser client)*); hosting
|
||||
needs the desktop app.
|
||||
|
||||
*Built* (sharing with a join code; `figcraft_collab::code`, `collab.share`, `collab.join {code}`):
|
||||
|
||||
@@ -590,7 +591,8 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
Whoever sees the code learns the host's address. Everyone who joins edits; a host without an
|
||||
IPv4 address gets no code and shares its link.
|
||||
- **In the browser** a code joins like a link (the page asks the server over its own WebSocket,
|
||||
then opens the session's); sharing needs the desktop app.
|
||||
then opens the session's); it shares online through the server in Preferences as the desktop
|
||||
app does, and not on the local network (a page can't listen).
|
||||
|
||||
*Built* (your online files; `figcraft_engine::online`, `collab.files`, `collab.openFile`,
|
||||
`collab.forgetFile`, `collab.manageFile`):
|
||||
@@ -605,7 +607,7 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
once when the list changes, so a crash doesn't lose a token. A file published with the server's
|
||||
key has no owner token and is the operator's to manage: it isn't recorded.
|
||||
- **The token is a password.** It is stored on this computer only (the desktop app's preferences
|
||||
file; the browser build keeps no preferences and can't publish), is never logged, and no
|
||||
file; the browser build keeps only your name across reloads, so a token it gets lasts as long as the page), is never logged, and no
|
||||
command result, summary or `ui.inspect` carries it or the owner's own links: `collab.files`
|
||||
says `hasOwnerToken: true`. The record's debug output leaves the secrets out. A session opened
|
||||
from a record holds no token either (`published.owner` is null there): requests that need it
|
||||
@@ -1008,7 +1010,7 @@ chooses to share on the network; the default is the invite link with a random to
|
||||
resuming connection), followed by client-scoped ids for document-level objects and remote text
|
||||
carets on the canvas.
|
||||
4. **Browser client** (WebSocket through `web-sys`). **Done** (the web build joins
|
||||
desktop-hosted sessions; `figcraft://` links open the desktop app and join).
|
||||
desktop-hosted sessions and publishes files to a server, #917; `figcraft://` links open the desktop app and join).
|
||||
5. **Standalone `figcraft-server`** with persistence, compaction, share links and permissions.
|
||||
**Done** (`apps/figcraft-server` on the shared hub; edit and view links, the server key,
|
||||
publishing from the app; see [`server.md`](server.md)), followed by open hosting for a public
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
@@ -18,7 +18,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
|
||||
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
|
||||
| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
|
||||
@@ -29,6 +29,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M9, #917: the browser build publishes to a `figcraft-server`. Share ▸ Online (the server in Preferences) and Share ▸ Publish to server… upload the open file over the page's own WebSocket, make the links and join codes others join with, and keep the tab in sync with the new file, as the desktop app does; asking a server about its hosting, stopping a share and sharing again go out the same way. The Share popover offers only online sharing there (a page can't listen, so the local network stays the desktop app's); without a server in Preferences it says so and offers joining. A failed publish says why and leaves the file as it was. The browser keeps only your name across reloads, so the owner token of a file it put on an open server lasts as long as the page (`crates/collab-net/src/exchange.rs`, run by the browser client's driver; the requests and what their answers mean are shared with the desktop transport in `requests.rs`). Tests: `exchange_tests` (scripted server: with and without the key, sharing or not, refusals, an old server without join codes, time-outs, leaving while publishing, the background requests), `live_join_tests`; checked in a browser against a local server. Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | Where an AI agent works (M8, #1416): an agent attached over MCP (`figcraft-cli mcp --attach`) changed the file with no sign of where or whether it was still at work. Now, while it runs commands, the people row at the top of the right panel shows its avatar, ringed in its own colour and named as its comments are signed ("Claude (agent)"), and the canvas outlines the layers its last edit changed (the innermost ones), or that it last selected, in that colour with its name. Both go 8 seconds after its last command, and only the file it works in shows them. Display only: no undo step, never saved, never sent to a live session's other people, and the user's own commands never make one (`agent_presence_tests` in the engine, `live::agent` tests in the UI). Ready unchanged |
|
||||
| 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged |
|
||||
| 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged |
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -75,6 +75,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M9, #917: the browser build publishes to a `figcraft-server`. Share ▸ Online (the server in Preferences) and Share ▸ Publish to server… upload the open file over the page's own WebSocket, make the links and join codes others join with, and keep the tab in sync with the new file, as the desktop app does; asking a server about its hosting, stopping a share and sharing again go out the same way. The Share popover offers only online sharing there (a page can't listen, so the local network stays the desktop app's); without a server in Preferences it says so and offers joining. A failed publish says why and leaves the file as it was. The browser keeps only your name across reloads, so the owner token of a file it put on an open server lasts as long as the page (`crates/collab-net/src/exchange.rs`, run by the browser client's driver; the requests and what their answers mean are shared with the desktop transport in `requests.rs`). Tests: `exchange_tests` (scripted server: with and without the key, sharing or not, refusals, an old server without join codes, time-outs, leaving while publishing, the background requests), `live_join_tests`; checked in a browser against a local server. Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M4, #1051: fonts installed on this computer in the browser build. Where the browser lists them (Chromium's Local Font Access, `window.queryLocalFonts`), the Design panel's font menu offers "Use fonts installed on this computer…"; the browser asks for permission, and the fonts join the font list by name, with their styles. A font's bytes are read only when text uses it, one style at a time (its preview in the menu waits until then), and auto-sizing text fits it once it is in; one that can't be read is dropped and its text keeps the fallback. A family the build embeds keeps its faces; the computer's styles it lacks join it. Other browsers show no row (`figcraft_text::add_local_fonts`, `apps/figcraft-web/src/local_fonts.rs`). Tests: `fontdb::local_tests`, `local_fonts_tests`; checked in a browser. Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M8, #1550: SVG import reads CSS colour functions its SVG parser doesn't (it painted them black, or left strokes out, without a word): `color(srgb …)` and `color(display-p3 …)`, with alpha, converted to the document's profile, and `color-mix(in srgb|oklab|oklch, …)`, evaluated at import (percentages, alpha and nested mixes as CSS Color 5 has them), in colour attributes, `style` attributes and `<style>` sheets. Other colour spaces are taken as sRGB values and other mixes mixed in sRGB, each with an import note; a call that can't be read becomes a mid grey with a note. Bounded: calls over 2,048 characters, mixes nested more than 8 deep and more than 100,000 calls are not read. Tests: `css_color_tests` (svg). Numbers unchanged |
|
||||
| 2026-10-30 | minor | M1, #1320: settings in portable mode: a `portable` file next to the executable keeps the settings, recovery copies and plugins in `config/` beside it (the Windows portable zip ships one), starting with the platform folder's preferences; the folder comes from craft-settings' rules, and without a marker it is the one every earlier version used. Tests: `settings_dir_tests` |
|
||||
|
||||
@@ -27,7 +27,8 @@ computer's fonts after the browser asks for permission; each is read only when t
|
||||
## Live sessions
|
||||
|
||||
The web build can join a live session hosted by the desktop app (File ▸ Join live session…, then
|
||||
paste the invite link); it can't host one. Desktop-hosted sessions use plain `ws://`, which
|
||||
paste the invite link), join files on a `figcraft-server`, and publish files to one (Share ▸ Online with
|
||||
the server in Preferences, or Share ▸ Publish to server…); it can't host a session itself. Desktop-hosted sessions use plain `ws://`, which
|
||||
browsers block from a page served over `https://` except to the same computer. To join sessions on
|
||||
other machines, serve the site over `http://` on a trusted network (or use the desktop app);
|
||||
`wss://` comes with the standalone server.
|
||||
|
||||
Reference in new issue
Block a user