diff --git a/.changes/share-native-compression.json b/.changes/share-native-compression.json new file mode 100644 index 00000000..e3453ecd --- /dev/null +++ b/.changes/share-native-compression.json @@ -0,0 +1 @@ +{ "type": "user-facing", "releaseNoteId": "release:0.17.1" } diff --git a/docs/pwa.md b/docs/pwa.md index 3ea2ee7d..2dd08879 100644 --- a/docs/pwa.md +++ b/docs/pwa.md @@ -77,7 +77,7 @@ support). Installability needs: HTTPS ✓, manifest with name + icons + `display **Tooling — `vite-plugin-pwa` (Workbox), `generateSW` mode.** It reads the Vite build and emits a precache manifest automatically, so the cache list can never -drift from the build. Hand-rolling the SW (as with the SHA / deflate fallbacks) +drift from the build. Hand-rolling the SW (as with the SHA fallback) buys nothing here — the precache-and-activate lifecycle is exactly what Workbox does well, and it is battle-tested. The plugin is added to `plugins` **only for the Production and PWA-test builds** (`mode === 'pwa'` or diff --git a/docs/specs/SEMANTICS-P.md b/docs/specs/SEMANTICS-P.md index ec685a41..f71148ac 100644 --- a/docs/specs/SEMANTICS-P.md +++ b/docs/specs/SEMANTICS-P.md @@ -265,7 +265,9 @@ additional authenticated data, none at all, 599,999 iterations. Measured sizes at v0.17.0: every bundled template in every shipped language fits the cap as a protected link; the largest is the MMO progression template in Thai, 7,000 characters plain and 7,059 protected, and 7,983 with the pure -JavaScript compressor used where `CompressionStream` is missing. +JavaScript compressor used where `CompressionStream` is missing. That compressor +was removed after this measurement (issue #301 decision 1, `SEMANTICS-U.md` +§U13): a page without `CompressionStream` now makes no link at all. ## P13. Not verified diff --git a/docs/specs/SEMANTICS-U.md b/docs/specs/SEMANTICS-U.md index d31c455d..92217cdd 100644 --- a/docs/specs/SEMANTICS-U.md +++ b/docs/specs/SEMANTICS-U.md @@ -10,6 +10,10 @@ implementation. A behavioural change after this is a new spec id in a new document (`loop-share/2`), exactly as with `loop-state/1 → loop-state/2` and `loop-workspace/1`; this file only takes typo / clarifying-prose fixes. +**Amended** (2026-10-04): decision D2 is replaced by the decision recorded in +§U13, and §U1.3, §U6, §U11 and §U12 follow it. The link format, the limits and +the opening order are unchanged. + Defines *what a share link is*, *what it carries*, *how it is produced*, and *how it is opened*. @@ -93,17 +97,20 @@ The compressed layer is **zlib-wrapped DEFLATE (RFC 1950)** — a 2-byte zlib header and a trailing Adler-32 checksum around an RFC 1951 DEFLATE stream — **not** raw DEFLATE (RFC 1951). Implementations: -- **`CompressionStream('deflate')` / `DecompressionStream('deflate')`** when the - context exposes them (these emit / accept the zlib wrapper by definition; - `'deflate-raw'` must **not** be used). -- A **bundled pure-JS zlib inflate *and* deflate** for the portable `file://` - build and any browser without Compression Streams — the same self-contained - fallback principle already accepted for SHA-256 in `loop-workspace/1` §W12. - The fallback covers **both directions**: a portable build must be able to - *produce* a link and to *open* one. - -Different compressor implementations (native vs. fallback, different levels) may -emit **different bytes** for the same graph. That is valid as long as every +- **`CompressionStream('deflate')` / `DecompressionStream('deflate')`**, in + both directions and in every build, the portable `file://` build included + (these emit / accept the zlib wrapper by definition; `'deflate-raw'` must + **not** be used). +- There is **no bundled fallback**. A page that lacks either stream can neither + make nor open a link: making one says so and makes nothing, and opening one + shows a notice, leaves the working graph untouched and leaves the fragment in + the address bar, so the same address opens in another browser. Every browser + the build targets has both streams. +- Links made by the pure-JS encoder that earlier builds bundled are conformant + zlib streams and must keep opening through `DecompressionStream` (§U12.8). + +Different compressor implementations (browsers, versions, levels, and the +encoder earlier builds bundled) may emit **different bytes** for the same graph. That is valid as long as every conformant decoder can inflate every conformant encoder's output. **There is no requirement that the same graph yields the same URL string**, and callers must not assume link stability across builds or sessions. Interop is mandatory: @@ -337,7 +344,8 @@ unsupported link or a Cancel. | build with `loop-share/1` | starts `g1` but is not `g1=…` (a **malformed** Share link) | normal boot + **console warning**; working graph + run **untouched**; **fragment stripped** | | build with `loop-share/1` | `g1=` fragment that fails any of base64url / inflate / size / parse / `deserialize` | normal boot + console warning; working graph + run **untouched**; **fragment stripped** | | build with `loop-share/1` | a fragment that is **not ours** (`#section`, `#/route`, `#w1=…`, `#gg=…`) | normal boot; working graph untouched; **fragment left as-is** | -| `https://` build ↔ portable `file://` build | either one's `g1=` link | **must** decode in the other (zlib interop + fallback inflate) | +| `https://` build ↔ portable `file://` build | either one's `g1=` link | **must** decode in the other (zlib interop; both use the browser's Compression Streams) | +| build with `loop-share/1` on a page **without** Compression Streams | any share link (`g1=` or `p1=`) | one notice: this browser cannot make or open share links; working graph + run **untouched**; **fragment left as-is** | - The **graph wire format is unchanged** (`loop-studio/graph` `version: 1`). A link is only a transport for the same bytes a `Graph JSON` export writes: a @@ -348,7 +356,7 @@ unsupported link or a Cancel. `w1=` prefix under a new spec id — not this document. - **Portable `file://` build:** a share link targets an `https://` host, so *opening* one needs the hosted app. The **encoder and decoder both** work in - the portable build via base64url and the bundled zlib fallback (§U1.3). + the portable build through the browser's own Compression Streams (§U1.3). --- @@ -398,7 +406,7 @@ unsupported link or a Cancel. | # | decision | |---|---| | **D1** | Payload in the URL **fragment**, never a query string. | -| **D2** | Encode = base64url of **zlib-wrapped DEFLATE (RFC 1950)** of the `serialize()` output. `CompressionStream('deflate')` when available; a bundled pure-JS zlib **inflate + deflate** fallback otherwise (portable `file://` and no-Compression-Streams). `deflate-raw` is not used. Cross-implementation byte differences are allowed; mutual decodability and `https ↔ file://` interop are required; URL-string stability is **not** promised. | +| **D2** | Encode = base64url of **zlib-wrapped DEFLATE (RFC 1950)** of the `serialize()` output. `CompressionStream('deflate')` / `DecompressionStream('deflate')` only, in every build; no bundled fallback (amended 2026-10-04, §U13). A page without them makes and opens no link and says so. `deflate-raw` is not used. Cross-implementation byte differences are allowed; mutual decodability and `https ↔ file://` interop are required; URL-string stability is **not** promised. | | **D3** | `SHARE_MAX_BYTES = 8 * 1024` — hard cap on the **base64url payload byte length after `#g1=`** (not whole-URL, not pre-compression). Over ⇒ hard reject, no truncation. | | **D3b** | `SHARE_MAX_DECODED_BYTES = 1024 * 1024` — decompression-bomb guard on the inflated bytes, enforced **incrementally**, aborting **before** `JSON.parse`. | | **D4** | A link carries the **`GraphDoc` only**. `view` / `canvas` framing is deferred to `loop-share/2`. | @@ -427,7 +435,7 @@ document, the graph / engine specs, or `loop-workspace/1`. | `__SHARE_BASE_URL__` | `https://cozy-loop-studio.pages.dev/` (env `VITE_SHARE_BASE_URL`) | the fixed public base a link is built on; **not** `location`. A non-`http(s)` value ⇒ `Share` errors, never a `null/…` link | | `SHARE_MAX_BYTES` | `8 * 1024` (8 KiB) | hard cap on the base64url payload byte length **after `#g1=`** | | `SHARE_MAX_DECODED_BYTES` | `1024 * 1024` (1 MiB) | incremental cap on inflated bytes; abort before `JSON.parse` | -| compression | **zlib-wrapped DEFLATE (RFC 1950)** | `CompressionStream('deflate')` or the bundled pure-JS zlib fallback (inflate **and** deflate); never `deflate-raw` | +| compression | **zlib-wrapped DEFLATE (RFC 1950)** | `CompressionStream('deflate')` / `DecompressionStream('deflate')` only, no bundled fallback; never `deflate-raw` | | base64url alphabet | `A–Za–z0–9-_`, no `=` padding | strict decode: any other char (incl. `+` `/` whitespace `=`) ⇒ invalid | | graph schema | `loop-studio/graph` `version: 1` | unchanged; the link is only a transport | @@ -465,17 +473,18 @@ document, the graph / engine specs, or `loop-workspace/1`. 7. **zlib wrapper, not raw** — a payload compressed as **raw** DEFLATE (`deflate-raw`) ⇒ rejected at inflate (no zlib header); a correctly zlib-wrapped payload decodes. -8. **Compressor interop** — a link made with `CompressionStream('deflate')` and - a link made with the pure-JS zlib fallback, for the same graph, **both** - decode to the identical graph (bytes / URL strings need not match). Tested - against standard RFC 1950/1951 vectors. +8. **Compressor interop** — a link made with `CompressionStream('deflate')` + decodes to the identical graph, and so does every fixed payload made by the + pure-JS encoder that earlier builds bundled (fixed-Huffman blocks and stored + blocks, recorded before it was removed): bytes / URL strings need not match. 9. **Decompression-bomb guard** — a crafted `g1=` payload that inflates past `SHARE_MAX_DECODED_BYTES` ⇒ inflation aborts **before** `JSON.parse`, the link is reported damaged, the current graph is untouched, output is never fully materialised. 10. **`https ↔ file://` interop** — a link produced by the portable single-file build opens correctly in the hosted build, and a link produced by the hosted - build opens in the portable build (decoder fallback path). + build opens in the portable build. A link the removed pure-JS encoder made + opens in both. 11. **Fragment classification (§U5.1)** — - `#g2=…` / `#g10=…` (unsupported version) and `#g1` / `#g1x` (malformed) ⇒ normal boot, working graph **and a running sim untouched**, `simulationRev` @@ -502,3 +511,18 @@ document, the graph / engine specs, or `loop-workspace/1`. export: identical; no id / email / timestamp / build field present. 17. **Old build** — a share link opened by a share-unaware build (simulate by disabling the loader) boots normally and ignores the fragment. +18. **No Compression Streams** — with `CompressionStream` removed, making a + plain or a protected link shows the one sentence, copies nothing and opens + no panel (a protected one before any key derivation). With + `DecompressionStream` removed, a `g1=` or a `p1=` link shows the notice, no + password is asked for, the working graph is untouched and the fragment + stays in the address bar. + +--- + +## U13. Change record + +| date | change | why | +|---|---|---| +| 2026-08-29 | Frozen. | | +| 2026-10-04 | §U1.3, §U6, D2, §U11 and §U12.8 / §U12.10 amended; §U12.18 added. The bundled pure-JS zlib inflate and deflate are removed: both directions use `CompressionStream` / `DecompressionStream` only, and a page without them makes and opens no link and says so. The link format, the size limits, the opening order and the fragment grammar are unchanged; links the removed encoder made still open. | Issue #301 decision 1. The fallback's origin could not be established from the repository's record, so it could not be listed with a notice. Every browser the build targets has Compression Streams, so the fallback ran only outside that target. | diff --git a/e2e/portable-file.spec.ts b/e2e/portable-file.spec.ts index 30d9ac14..7233baad 100644 --- a/e2e/portable-file.spec.ts +++ b/e2e/portable-file.spec.ts @@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test' import { capturedExports, installProbe, pathProbe, portableUrl } from './support/mc' import { expectOneVersionStory, readAboutVersion, readNewestShown, seedWhatsNewSeen } from './support/whatsNew' import { RELEASE_NOTES } from '../src/releaseNotes/releaseNotes' +import { LEGACY_SHARE_VECTORS } from '../src/model/shareLegacy.fixture' // SLICE-2 §5–§6: the portable single-file build opened from file://. No dev // server, no window.__loop bridge (production build) — driven entirely through @@ -398,26 +399,18 @@ test.describe('portable file://', () => { }) /** Open the real portable page, click Share (accepting the §U4 disclosure), - * and return the URL shown in the field + what hit the (stubbed) clipboard. - * `killCompressionStream` forces the self-contained fixed-Huffman deflate. */ - async function shareFromPortable( - browser: import('@playwright/test').Browser, - killCompressionStream: boolean, - ): Promise<{ url: string; clip: string[] }> { + * and return the URL shown in the field + what hit the (stubbed) clipboard. */ + async function shareFromPortable(browser: import('@playwright/test').Browser): Promise<{ url: string; clip: string[] }> { const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }) await seedPersonalBrowser(ctx) // issue #297 - no storage gate in front of this context const page = await ctx.newPage() - await page.addInitScript((kill: boolean) => { - if (kill) { - // @ts-expect-error removing a global for the test - delete window.CompressionStream - } + await page.addInitScript(() => { ;(window as any).__clip = [] Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (t: string) => void (window as any).__clip.push(t) }, }) - }, killCompressionStream) + }) await openPortable(page) // imports risky-factory (18 nodes) // the §U4 disclosure is an in-app ConfirmDialog now (docs/localization.md 2b) await page.locator('.toolbar__actions button', { hasText: /^Share$/ }).click() @@ -459,12 +452,56 @@ test.describe('portable file://', () => { await ctx.close() } - // codec check (keep): the pure-JS fixed-Huffman deflate output decodes on the - // native path across contexts. - test('pure-JS deflate link (file://) decodes on the hosted build', async ({ browser }) => { + // issue #301 decision 1 - the bundled pure-JS zlib encoder is gone, and the + // links it made are still out there: one of them, made by that encoder as it + // shipped on main 938aae3, must keep opening on the hosted build and in the + // portable file, through the browser's own DecompressionStream. + const LEGACY_RISKY = LEGACY_SHARE_VECTORS.find((v) => v.name.startsWith('risky-factory'))! + + test('a link the removed pure-JS encoder made opens on the hosted build', async ({ browser }) => { test.setTimeout(60_000) - const { url } = await shareFromPortable(browser, /* kill CompressionStream */ true) - await openPayloadOnHttp(browser, url.split('#g1=')[1]) + await openPayloadOnHttp(browser, LEGACY_RISKY.payload) + }) + + test('a link the removed pure-JS encoder made opens in the portable file', async ({ browser }) => { + test.setTimeout(60_000) + const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }) + const page = await ctx.newPage() + await page.goto(portableUrl() + '#g1=' + LEGACY_RISKY.payload) + expect(await page.evaluate(() => location.protocol)).toBe('file:') + await answerPortableGate(page, 'temporary') + await expect(page.locator('.react-flow__node')).toHaveCount(18) + await expect(page.locator('.react-flow__node', { hasText: 'Ore Source' })).toHaveCount(1) + await page.waitForFunction(() => location.hash === '') // consumed + stripped + await ctx.close() + }) + + // and with no Compression Streams the portable file makes no link at all: + // there is no fallback to make one with + test('without CompressionStream the portable file makes no link and says so', async ({ browser }) => { + test.setTimeout(60_000) + const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }) + const page = await ctx.newPage() + await page.addInitScript(() => { + delete (window as any).CompressionStream + ;(window as any).__clip = [] + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: async (t: string) => void (window as any).__clip.push(t) }, + }) + }) + await openPortable(page) + const alerts: string[] = [] + page.on('dialog', (d) => { + alerts.push(d.message()) + void d.accept() + }) + await page.locator('.toolbar__actions button', { hasText: /^Share$/ }).click() + await page.locator('.mcdlg--confirm').getByRole('button', { name: /create link/i }).click() + await expect.poll(() => alerts).toEqual(['This browser cannot create or open share links. Try again in a current browser.']) + await expect(page.locator('.share-pop__url')).toHaveCount(0) + expect(await page.evaluate(() => (window as any).__clip as string[])).toEqual([]) + await ctx.close() }) // real user path: the Share button on the actual file:// screen must produce a @@ -474,7 +511,7 @@ test.describe('portable file://', () => { browser, }) => { test.setTimeout(60_000) - const { url, clip } = await shareFromPortable(browser, /* keep CompressionStream */ false) + const { url, clip } = await shareFromPortable(browser) expect(url).toMatch(/^https:\/\/cozy-loop-studio\.pages\.dev\/#g1=[A-Za-z0-9_-]+$/) expect(url).not.toContain('null/') diff --git a/e2e/share-protected.spec.ts b/e2e/share-protected.spec.ts index bb7c1223..7e2a8fee 100644 --- a/e2e/share-protected.spec.ts +++ b/e2e/share-protected.spec.ts @@ -2,6 +2,7 @@ import { createCipheriv, pbkdf2Sync, randomBytes } from 'node:crypto' import { deflateSync } from 'node:zlib' import type { Browser, BrowserContext, Page } from '@playwright/test' import { expect, openApp, resetAll, seedPersonalBrowser, test } from './support/loop' +import { LEGACY_SHARE_VECTORS } from '../src/model/shareLegacy.fixture' // SEMANTICS-P.md loop-share-protected/1 (issue #300) — a share link protected // with a password, through the real UI: the choice in the share dialog, the @@ -453,6 +454,82 @@ test.describe('opening a link', () => { }) }) +// issue #301 decision 1 - there is no bundled zlib fallback: a page without +// Compression Streams makes and opens no share link of either kind, says so in +// one sentence, and leaves a link it was handed in the address bar +// (SEMANTICS-U.md SS U1.3) +test.describe('without Compression Streams', () => { + const SENTENCE = 'This browser cannot create or open share links. Try again in a current browser.' + const RISKY = LEGACY_SHARE_VECTORS.find((v) => v.name.startsWith('risky-factory'))! + + async function pageWithout(browser: Browser, global: 'CompressionStream' | 'DecompressionStream'): Promise<{ ctx: BrowserContext; page: Page }> { + const ctx = await browser.newContext() + await seedPersonalBrowser(ctx) + await stubClipboard(ctx) + await ctx.addInitScript((name: string) => { + delete (window as any)[name] + }, global) + return { ctx, page: await ctx.newPage() } + } + + test('a plain link is not made: the sentence is alerted, nothing is copied, no panel opens', async ({ browser }) => { + const { ctx, page } = await pageWithout(browser, 'CompressionStream') + await openApp(page) + const alerts: string[] = [] + page.on('dialog', (d) => { + alerts.push(d.message()) + void d.accept() + }) + await shareBtn(page).click() + await createDialog(page).getByRole('button', { name: 'Create link' }).click() + await expect.poll(() => alerts).toEqual([SENTENCE]) + await expect(page.locator('.share-pop__url')).toHaveCount(0) + expect(await clipWrites(page)).toEqual([]) + await ctx.close() + }) + + test('a protected link is not made: the sentence is said inside the dialog, before any key derivation', async ({ browser }) => { + const { ctx, page } = await pageWithout(browser, 'CompressionStream') + await countDerivations(ctx) + await openApp(page) + await shareBtn(page).click() + await field(page, 'option').check() + await field(page, 'password').fill(PASSWORD) + await field(page, 'confirm').fill(PASSWORD) + await createDialog(page).getByRole('button', { name: 'Create link' }).click() + await expect(field(page, 'problem')).toHaveText(SENTENCE) + await expect(createDialog(page)).toBeVisible() + expect(await page.evaluate(() => (window as any).__derives as number)).toBe(0) + expect(await clipWrites(page)).toEqual([]) + await ctx.close() + }) + + test('a plain link is not opened: a "Share link" notice, the fragment kept, the diagram untouched', async ({ browser }) => { + const { ctx, page } = await pageWithout(browser, 'DecompressionStream') + await freshGoto(page, '/#g1=' + RISKY.payload) + await expect(notice(page)).toHaveAttribute('data-notice', 'plain-no-compression') + await expect(notice(page).locator('.mcdlg__head')).toHaveText('Share link') + await expect(notice(page).locator('.mcdlg__note')).toHaveText(SENTENCE) + expect(await hashOf(page)).toBe('#g1=' + RISKY.payload) + await notice(page).getByRole('button', { name: 'Close' }).click() + await expect(notice(page)).toHaveCount(0) + expect(await hashOf(page)).toBe('#g1=' + RISKY.payload) + expect(await labelsOf(page)).not.toContain('Ore Source') + await ctx.close() + }) + + test('a protected link is not opened: the same sentence, no password asked, the fragment kept', async ({ browser }) => { + const { hash } = await linkFromAnotherProfile(browser) + const { ctx, page } = await pageWithout(browser, 'DecompressionStream') + await freshGoto(page, '/' + hash) + await expect(notice(page)).toHaveAttribute('data-notice', 'no-compression') + await expect(notice(page).locator('.mcdlg__note')).toHaveText(SENTENCE) + await expect(prompt(page)).toHaveCount(0) + expect(await hashOf(page)).toBe(hash) + await ctx.close() + }) +}) + test.describe('the first-run Welcome card', () => { test('waits behind the prompt and comes after it', async ({ browser }) => { const { hash } = await linkFromAnotherProfile(browser) diff --git a/e2e/whats-new.spec.ts b/e2e/whats-new.spec.ts index 703b3e6e..bd2cb50d 100644 --- a/e2e/whats-new.spec.ts +++ b/e2e/whats-new.spec.ts @@ -239,7 +239,7 @@ test.describe('closing the notice and opening the panel are different things', ( expect(text).not.toContain('text unavailable') } } - expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.17.0', '0.16.0', '0.15.3', '0.15.2', '0.15.1', '0.15.0', '0.14.0']) + expect(RELEASE_NOTES.map((n) => n.version)).toEqual(['0.17.1', '0.17.0', '0.16.0', '0.15.3', '0.15.2', '0.15.1', '0.15.0', '0.14.0']) await page.keyboard.press('Escape') await expect(panel).toHaveCount(0) diff --git a/package-lock.json b/package-lock.json index d6cca7c4..8961c0eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loop-studio", - "version": "0.17.0", + "version": "0.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loop-studio", - "version": "0.17.0", + "version": "0.17.1", "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", diff --git a/package.json b/package.json index 9a6675a0..c6ec6860 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loop-studio", "private": true, - "version": "0.17.0", + "version": "0.17.1", "type": "module", "engines": { "node": ">=22.12.0" diff --git a/src/components/ProtectedLinkDialog.tsx b/src/components/ProtectedLinkDialog.tsx index d9895e70..9f64f5c4 100644 --- a/src/components/ProtectedLinkDialog.tsx +++ b/src/components/ProtectedLinkDialog.tsx @@ -32,8 +32,15 @@ const NOTICE_KEY = { unavailable: 'share.open.notice.unavailable', newer: 'share.open.notice.newer', content: 'share.open.notice.content', + // issue #301 decision 1 - the same sentence for either kind of link + 'no-compression': 'share.unavailable', + 'plain-no-compression': 'share.unavailable', } as const satisfies Record +/** a plain `g1` link is not a "protected link": its notice is titled "Share link" */ +const noticeTitleKey = (notice: ProtectedNotice): MessageKey => + notice === 'plain-no-compression' ? 'share.panel.label' : 'share.open.notice.title' + export function ProtectedLinkDialog() { const open = useProtectedLinkStore((s) => s.open) if (open.phase === 'prompt') return @@ -169,7 +176,7 @@ function Notice({ notice }: { notice: ProtectedNotice }) { onMouseDown={(e) => e.stopPropagation()} >
- {t('share.open.notice.title')} + {t(noticeTitleKey(notice))}

diff --git a/src/components/mobile/MobileMoreMenu.tsx b/src/components/mobile/MobileMoreMenu.tsx index 32f14289..c5efdf36 100644 --- a/src/components/mobile/MobileMoreMenu.tsx +++ b/src/components/mobile/MobileMoreMenu.tsx @@ -146,6 +146,10 @@ export function MobileMoreMenu({ window.alert(t('share.noBase')) return } + if (result.status === 'no-compression') { + window.alert(t('share.unavailable')) + return + } const copied = await copyShareLink(result.url) setSharePanel({ url: result.url, copied, copies: copied ? 1 : 0 }) openOverlay('share') diff --git a/src/components/toolbar/ShareCreateDialog.tsx b/src/components/toolbar/ShareCreateDialog.tsx index c7ea4d62..ee656ba6 100644 --- a/src/components/toolbar/ShareCreateDialog.tsx +++ b/src/components/toolbar/ShareCreateDialog.tsx @@ -26,7 +26,7 @@ import { shareDisclosureBody } from './shareDisclosure' // size cap, no public address, no Web Crypto) is shown here, inline. type Problem = - | { kind: 'short' | 'long' | 'mismatch' | 'unavailable' | 'no-base' } + | { kind: 'short' | 'long' | 'mismatch' | 'unavailable' | 'no-base' | 'no-compression' } | { kind: 'too-large'; bytes: number; cap: number } type Props = { @@ -47,6 +47,8 @@ const PROBLEM_KEY = { mismatch: 'share.protect.error.mismatch', unavailable: 'share.protect.unavailable', 'no-base': 'share.noBase', + // issue #301 decision 1 - no Compression Streams: no link of either kind here + 'no-compression': 'share.unavailable', } as const satisfies Record // Closed = forgotten. The body is mounted only while the dialog is open, so the diff --git a/src/components/toolbar/useShareSurface.ts b/src/components/toolbar/useShareSurface.ts index 82059e0b..2474e0eb 100644 --- a/src/components/toolbar/useShareSurface.ts +++ b/src/components/toolbar/useShareSurface.ts @@ -52,6 +52,10 @@ export function useShareSurface() { window.alert(t('share.noBase')) return } + if (result.status === 'no-compression') { + window.alert(t('share.unavailable')) + return + } const url = result.url // Clipboard API missing or denied ⇒ false: the field below is the fallback diff --git a/src/i18n/esEsCopy.test.ts b/src/i18n/esEsCopy.test.ts index d43c6756..7a3eca3b 100644 --- a/src/i18n/esEsCopy.test.ts +++ b/src/i18n/esEsCopy.test.ts @@ -128,6 +128,9 @@ describe('es-ES — the region audit, as a contract', () => { 'share.panel.protected', 'share.protect.note.channel', 'share.protect.note.contract', + // issue #301 decision 1 - the browser that can make no share link: + // `usted` (`Vuelva`) where es-419 says `tú` (`Vuelve`) + 'share.unavailable', // the formatter emits a NO-BREAK SPACE before the percent sign 'playbar.mc.progress', 'runbar.mc.cancel', diff --git a/src/i18n/itCopy.test.ts b/src/i18n/itCopy.test.ts index e367b0ea..15ed4b84 100644 --- a/src/i18n/itCopy.test.ts +++ b/src/i18n/itCopy.test.ts @@ -199,15 +199,15 @@ const IDENTICAL_TO_EN: ReadonlyArray = [ // ------------------------------------------------------------------ shape describe('it copy — the surfaces exist and are complete', () => { it('has exactly the base key set', () => { - expect(KEYS).toHaveLength(977) + expect(KEYS).toHaveLength(981) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines) expect(Object.keys(IT).sort()).toEqual([...KEYS].sort()) }) it('covers all four runtime surfaces, at the measured sizes', () => { const per: Record = {} for (const r of RUNTIME) per[r.surface.split('/')[0]!] = (per[r.surface.split('/')[0]!] ?? 0) + 1 - expect(per).toEqual({ catalog: 977, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1199) + expect(per).toEqual({ catalog: 981, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1203) }) it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => { diff --git a/src/i18n/locales/ar/ui.ts b/src/i18n/locales/ar/ui.ts index 7ca91ec2..711c6ee7 100644 --- a/src/i18n/locales/ar/ui.ts +++ b/src/i18n/locales/ar/ui.ts @@ -553,6 +553,12 @@ const ui = { 'whatsNew.v0170.open': 'يطلب الرابط المحمي كلمة المرور قبل عرض أي شيء من المخطط المشترك. كلمة المرور الخاطئة وبيانات الرابط المشفّرة التي غُيّرت بعد إنشائه تعطيان الرسالة نفسها.', 'whatsNew.v0170.limits': 'لا يمكن استرجاع كلمة مرور مفقودة، وقوة الرابط من قوة كلمة المرور. لا يخزّن Loop Studio كلمة المرور ولا المفتاح ولا يرسلهما.', 'whatsNew.v0170.plain': 'الرابط العادي ما زال الخيار الافتراضي ويعمل كما كان.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'لا يمكن لهذا المتصفح إنشاء روابط المشاركة أو فتحها. أعد المحاولة في متصفح حديث.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'تستخدم روابط المشاركة الآن الضغط المدمج في المتصفح.', + 'whatsNew.v0171.unavailable': 'إذا لم تكن هذه الميزة متاحة، يُبقي Loop Studio المخطط الحالي كما هو ويوضح أن المطلوب متصفح حديث.', + 'whatsNew.v0171.compatible': 'تظل روابط المشاركة الحالية متوافقة، ولم يتغير تنسيق الرابط.', } as const export default ui diff --git a/src/i18n/locales/de/ui.ts b/src/i18n/locales/de/ui.ts index d8812bcd..740862ca 100644 --- a/src/i18n/locales/de/ui.ts +++ b/src/i18n/locales/de/ui.ts @@ -588,6 +588,12 @@ const ui = { 'whatsNew.v0170.open': 'Ein geschützter Link fragt nach seinem Passwort, bevor irgendetwas vom geteilten Diagramm gezeigt wird. Ein falsches Passwort und verschlüsselte Linkdaten, die nach dem Erstellen verändert wurden, ergeben dieselbe Meldung.', 'whatsNew.v0170.limits': 'Ein verlorenes Passwort lässt sich nicht wiederherstellen, und der Link ist nur so stark wie sein Passwort. Loop Studio speichert und überträgt weder das Passwort noch den Schlüssel.', 'whatsNew.v0170.plain': 'Ein einfacher Link bleibt die Voreinstellung und funktioniert wie bisher.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'Dieser Browser kann keine Links zum Teilen erstellen oder öffnen. Bitte in einem aktuellen Browser erneut versuchen.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'Links zum Teilen nutzen jetzt die eingebaute Komprimierung des Browsers.', + 'whatsNew.v0171.unavailable': 'Ist diese Funktion nicht verfügbar, lässt Loop Studio das aktuelle Diagramm unverändert und weist darauf hin, dass ein aktueller Browser nötig ist.', + 'whatsNew.v0171.compatible': 'Bestehende Links zum Teilen bleiben kompatibel; das Linkformat hat sich nicht geändert.', } satisfies Record export default ui diff --git a/src/i18n/locales/en/ui.ts b/src/i18n/locales/en/ui.ts index e733163f..87b546e7 100644 --- a/src/i18n/locales/en/ui.ts +++ b/src/i18n/locales/en/ui.ts @@ -497,6 +497,12 @@ const ui = { 'whatsNew.v0170.open': 'A protected link asks for its password before anything from the shared diagram is shown. A wrong password and encrypted link data that was altered after creation give the same message.', 'whatsNew.v0170.limits': 'A lost password cannot be recovered, and the link is only as strong as its password. Loop Studio does not store or transmit the password or the key.', 'whatsNew.v0170.plain': 'A plain link is still the default and works as before.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'This browser cannot create or open share links. Try again in a current browser.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'Share links now use the browser’s built-in compression.', + 'whatsNew.v0171.unavailable': 'If that feature is unavailable, Loop Studio keeps your current diagram unchanged and explains that a current browser is required.', + 'whatsNew.v0171.compatible': 'Existing share links remain compatible; the link format has not changed.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/es-419/ui.ts b/src/i18n/locales/es-419/ui.ts index cca571c3..fcdcf619 100644 --- a/src/i18n/locales/es-419/ui.ts +++ b/src/i18n/locales/es-419/ui.ts @@ -511,6 +511,12 @@ const ui = { 'whatsNew.v0170.open': 'Un enlace protegido pide su contraseña antes de mostrar nada del diagrama compartido. Una contraseña incorrecta y unos datos cifrados del enlace que se alteraron después de crearlo dan el mismo mensaje.', 'whatsNew.v0170.limits': 'Una contraseña perdida no se puede recuperar, y el enlace es tan fuerte como su contraseña. Loop Studio no almacena ni transmite la contraseña ni la clave.', 'whatsNew.v0170.plain': 'Un enlace sin protección sigue siendo la opción predeterminada y funciona como antes.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'Este navegador no puede crear ni abrir enlaces para compartir. Vuelve a intentarlo en un navegador actual.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'Los enlaces para compartir ahora usan la compresión integrada del navegador.', + 'whatsNew.v0171.unavailable': 'Si esa función no está disponible, Loop Studio deja el diagrama actual sin cambios y explica que se necesita un navegador actual.', + 'whatsNew.v0171.compatible': 'Los enlaces para compartir existentes siguen siendo compatibles; el formato del enlace no ha cambiado.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/es-ES/ui.ts b/src/i18n/locales/es-ES/ui.ts index b5ae7a7a..3f7fecd9 100644 --- a/src/i18n/locales/es-ES/ui.ts +++ b/src/i18n/locales/es-ES/ui.ts @@ -525,6 +525,12 @@ const ui = { 'whatsNew.v0170.open': 'Un enlace protegido pide su contraseña antes de mostrar nada del diagrama compartido. Una contraseña incorrecta y unos datos cifrados del enlace que se alteraron después de crearlo dan el mismo mensaje.', 'whatsNew.v0170.limits': 'Una contraseña perdida no se puede recuperar, y el enlace es tan fuerte como su contraseña. Loop Studio no almacena ni transmite la contraseña ni la clave.', 'whatsNew.v0170.plain': 'Un enlace sin protección sigue siendo la opción predeterminada y funciona como antes.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'Este navegador no puede crear ni abrir enlaces para compartir. Vuelva a intentarlo en un navegador actual.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'Los enlaces para compartir ahora usan la compresión integrada del navegador.', + 'whatsNew.v0171.unavailable': 'Si esa función no está disponible, Loop Studio deja el diagrama actual sin cambios y explica que se necesita un navegador actual.', + 'whatsNew.v0171.compatible': 'Los enlaces para compartir existentes siguen siendo compatibles; el formato del enlace no ha cambiado.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/fr/ui.ts b/src/i18n/locales/fr/ui.ts index 630ed4d5..e00ea0f7 100644 --- a/src/i18n/locales/fr/ui.ts +++ b/src/i18n/locales/fr/ui.ts @@ -582,6 +582,12 @@ const ui = { 'whatsNew.v0170.open': 'Un lien protégé demande son mot de passe avant que quoi que ce soit du diagramme partagé ne soit affiché. Un mot de passe incorrect et des données de lien chiffrées qui ont été modifiées après la création donnent le même message.', 'whatsNew.v0170.limits': 'Un mot de passe perdu ne peut pas être récupéré, et le lien n’est pas plus solide que son mot de passe. Loop Studio ne conserve ni ne transmet le mot de passe ou la clé.', 'whatsNew.v0170.plain': 'Un lien simple reste le choix par défaut et fonctionne comme avant.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'Ce navigateur ne peut ni créer ni ouvrir de liens de partage. Réessayez dans un navigateur récent.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'Les liens de partage utilisent désormais la compression intégrée du navigateur.', + 'whatsNew.v0171.unavailable': 'Si cette fonction n’est pas disponible, Loop Studio laisse le diagramme actuel inchangé et explique qu’un navigateur récent est nécessaire.', + 'whatsNew.v0171.compatible': 'Les liens de partage existants restent compatibles et leur format n’a pas changé.', } satisfies Record export default ui diff --git a/src/i18n/locales/it/ui.ts b/src/i18n/locales/it/ui.ts index d44dc6bc..59b00817 100644 --- a/src/i18n/locales/it/ui.ts +++ b/src/i18n/locales/it/ui.ts @@ -527,6 +527,12 @@ const ui = { 'whatsNew.v0170.open': 'Un link protetto chiede la password prima che venga mostrato qualcosa del diagramma condiviso. Una password sbagliata e dati cifrati del link modificati dopo la creazione danno lo stesso messaggio.', 'whatsNew.v0170.limits': 'Una password persa non si può recuperare, e il link è forte solo quanto la sua password. Loop Studio non conserva né trasmette la password o la chiave.', 'whatsNew.v0170.plain': 'Il link semplice resta la scelta predefinita e funziona come prima.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'Questo browser non può creare né aprire link di condivisione. Riprova in un browser aggiornato.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'I link di condivisione ora usano la compressione integrata del browser.', + 'whatsNew.v0171.unavailable': 'Se questa funzione non è disponibile, Loop Studio lascia invariato il diagramma attuale e spiega che serve un browser aggiornato.', + 'whatsNew.v0171.compatible': 'I link di condivisione esistenti restano compatibili; il formato dei link non è cambiato.', } satisfies Record export default ui diff --git a/src/i18n/locales/ja/ui.ts b/src/i18n/locales/ja/ui.ts index d73b7406..3a946960 100644 --- a/src/i18n/locales/ja/ui.ts +++ b/src/i18n/locales/ja/ui.ts @@ -532,6 +532,12 @@ const ui = { 'whatsNew.v0170.open': '保護されたリンクは、共有されたダイアグラムの内容を何も表示する前にパスワードを尋ねます。パスワードの誤りと、作成後に変更された暗号化リンクデータでは、同じメッセージが表示されます。', 'whatsNew.v0170.limits': '忘れたパスワードは復元できず、リンクの強さはパスワードの強さで決まります。Loop Studio はパスワードも鍵も保存せず、送信もしません。', 'whatsNew.v0170.plain': '通常のリンクが引き続き既定で、これまでどおり動作します。', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'このブラウザーでは共有リンクを作成することも開くこともできません。最新のブラウザーでもう一度お試しください。', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': '共有リンクはブラウザーに組み込まれた圧縮機能を使うようになりました。', + 'whatsNew.v0171.unavailable': 'この機能を使えない場合は、現在のダイアグラムをそのまま保ち、最新のブラウザーが必要であることを案内します。', + 'whatsNew.v0171.compatible': '既存の共有リンクとの互換性は保たれ、リンクの形式も変わっていません。', } satisfies Record export default ui diff --git a/src/i18n/locales/ko/ui.ts b/src/i18n/locales/ko/ui.ts index 16ba5090..66abcf52 100644 --- a/src/i18n/locales/ko/ui.ts +++ b/src/i18n/locales/ko/ui.ts @@ -500,6 +500,12 @@ const ui = { 'whatsNew.v0170.open': '보호된 링크는 공유된 다이어그램의 어떤 것도 보이기 전에 비밀번호를 묻습니다. 틀린 비밀번호와 생성된 뒤 변경된 암호화 링크 데이터에는 같은 안내가 나옵니다.', 'whatsNew.v0170.limits': '잊은 비밀번호는 되찾을 수 없고, 링크는 비밀번호만큼만 안전합니다. Loop Studio는 비밀번호와 키를 저장하거나 전송하지 않습니다.', 'whatsNew.v0170.plain': '일반 링크가 여전히 기본이며 이전과 같이 동작합니다.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': '이 브라우저에서는 공유 링크를 만들거나 열 수 없습니다. 최신 브라우저에서 다시 시도하세요.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': '공유 링크는 이제 브라우저의 기본 압축 기능을 사용합니다.', + 'whatsNew.v0171.unavailable': '이 기능을 사용할 수 없으면 현재 다이어그램을 그대로 유지하고 최신 브라우저가 필요하다고 안내합니다.', + 'whatsNew.v0171.compatible': '기존 공유 링크와의 호환성은 유지되며 링크 형식도 바뀌지 않았습니다.', } satisfies Record export default ui diff --git a/src/i18n/locales/nl/ui.ts b/src/i18n/locales/nl/ui.ts index ab2e59ff..b9fe7877 100644 --- a/src/i18n/locales/nl/ui.ts +++ b/src/i18n/locales/nl/ui.ts @@ -508,6 +508,12 @@ const ui = { 'whatsNew.v0170.open': 'Een beveiligde link vraagt om het wachtwoord voordat er iets van het gedeelde diagram wordt getoond. Een verkeerd wachtwoord en versleutelde linkgegevens die na het maken zijn gewijzigd, geven dezelfde melding.', 'whatsNew.v0170.limits': 'Een verloren wachtwoord kan niet worden teruggehaald, en de link is maar zo sterk als het wachtwoord. Loop Studio bewaart en verstuurt het wachtwoord en de sleutel niet.', 'whatsNew.v0170.plain': 'Een gewone link blijft de standaard en werkt zoals voorheen.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'Deze browser kan geen deellinks maken of openen. Probeer het opnieuw in een actuele browser.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'Deellinks gebruiken nu de ingebouwde compressie van de browser.', + 'whatsNew.v0171.unavailable': 'Is die functie niet beschikbaar, dan laat Loop Studio het huidige diagram ongewijzigd en legt uit dat een actuele browser nodig is.', + 'whatsNew.v0171.compatible': 'Bestaande deellinks blijven compatibel; de opbouw van de link is niet veranderd.', } as const export default ui diff --git a/src/i18n/locales/pt-BR/ui.ts b/src/i18n/locales/pt-BR/ui.ts index 3ab79be7..4a938ed4 100644 --- a/src/i18n/locales/pt-BR/ui.ts +++ b/src/i18n/locales/pt-BR/ui.ts @@ -513,6 +513,12 @@ const ui = { 'whatsNew.v0170.open': 'Um link protegido pede a senha antes de mostrar qualquer coisa do diagrama compartilhado. Uma senha errada e dados criptografados do link que foram alterados depois da criação dão a mesma mensagem.', 'whatsNew.v0170.limits': 'Uma senha perdida não pode ser recuperada, e o link é tão forte quanto a senha. O Loop Studio não armazena nem transmite a senha nem a chave.', 'whatsNew.v0170.plain': 'O link simples continua sendo o padrão e funciona como antes.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'Este navegador não consegue criar nem abrir links de compartilhamento. Tente de novo em um navegador atual.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'Os links de compartilhamento agora usam a compressão integrada do navegador.', + 'whatsNew.v0171.unavailable': 'Se esse recurso não estiver disponível, o Loop Studio mantém o diagrama atual sem alterações e explica que é preciso um navegador atual.', + 'whatsNew.v0171.compatible': 'Os links de compartilhamento existentes continuam compatíveis; o formato do link não mudou.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/pt-PT/ui.ts b/src/i18n/locales/pt-PT/ui.ts index 6ffb8d15..3d1d5dcd 100644 --- a/src/i18n/locales/pt-PT/ui.ts +++ b/src/i18n/locales/pt-PT/ui.ts @@ -527,6 +527,12 @@ const ui = { 'whatsNew.v0170.open': 'Uma ligação protegida pede a palavra-passe antes de mostrar qualquer coisa do diagrama partilhado. Uma palavra-passe errada e dados cifrados da ligação que foram alterados depois da criação dão a mesma mensagem.', 'whatsNew.v0170.limits': 'Uma palavra-passe perdida não pode ser recuperada, e a ligação é tão forte quanto a palavra-passe. O Loop Studio não armazena nem transmite a palavra-passe nem a chave.', 'whatsNew.v0170.plain': 'A ligação simples continua a ser a predefinição e funciona como antes.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'Este navegador não consegue criar nem abrir ligações de partilha. Tente novamente num navegador atual.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'As ligações de partilha passam a usar a compressão integrada do navegador.', + 'whatsNew.v0171.unavailable': 'Se essa funcionalidade não estiver disponível, o Loop Studio mantém o diagrama atual sem alterações e explica que é necessário um navegador atual.', + 'whatsNew.v0171.compatible': 'As ligações de partilha existentes continuam compatíveis; o formato da ligação não mudou.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/ru/ui.ts b/src/i18n/locales/ru/ui.ts index b3d46407..c0ba3e34 100644 --- a/src/i18n/locales/ru/ui.ts +++ b/src/i18n/locales/ru/ui.ts @@ -581,6 +581,12 @@ const ui = { 'whatsNew.v0170.open': 'Защищённая ссылка спрашивает пароль до того, как будет показано что-либо из общей диаграммы. Неверный пароль и зашифрованные данные ссылки, изменённые после создания, дают одно и то же сообщение.', 'whatsNew.v0170.limits': 'Потерянный пароль нельзя восстановить, а ссылка надёжна ровно настолько, насколько надёжен пароль. Loop Studio не хранит и не передаёт ни пароль, ни ключ.', 'whatsNew.v0170.plain': 'Обычная ссылка остаётся вариантом по умолчанию и работает как раньше.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'Этот браузер не может создавать и открывать ссылки для доступа. Попробуйте снова в современном браузере.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'Ссылки для доступа теперь используют встроенное сжатие браузера.', + 'whatsNew.v0171.unavailable': 'Если эта функция недоступна, Loop Studio оставляет текущую диаграмму без изменений и сообщает, что нужен современный браузер.', + 'whatsNew.v0171.compatible': 'Существующие ссылки для доступа остаются совместимыми, формат ссылки не изменился.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/th/ui.ts b/src/i18n/locales/th/ui.ts index 86452b33..b63776a0 100644 --- a/src/i18n/locales/th/ui.ts +++ b/src/i18n/locales/th/ui.ts @@ -573,6 +573,12 @@ const ui = { 'whatsNew.v0170.open': 'ลิงก์ที่ป้องกันไว้จะถามรหัสผ่านก่อนแสดงสิ่งใดจากไดอะแกรมที่แชร์ รหัสผ่านที่ผิดและข้อมูลลิงก์ที่เข้ารหัสซึ่งถูกเปลี่ยนแปลงหลังจากสร้างจะได้ข้อความเดียวกัน', 'whatsNew.v0170.limits': 'รหัสผ่านที่หายไปกู้คืนไม่ได้ และลิงก์ปลอดภัยได้เท่ากับรหัสผ่านเท่านั้น Loop Studio ไม่จัดเก็บและไม่ส่งรหัสผ่านหรือกุญแจ', 'whatsNew.v0170.plain': 'ลิงก์ธรรมดายังคงเป็นค่าเริ่มต้นและทำงานเหมือนเดิม', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'เบราว์เซอร์นี้สร้างหรือเปิดลิงก์แชร์ไม่ได้ ลองอีกครั้งในเบราว์เซอร์รุ่นปัจจุบัน', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'ลิงก์แชร์ใช้การบีบอัดที่มีในเบราว์เซอร์แล้ว', + 'whatsNew.v0171.unavailable': 'หากใช้ฟีเจอร์นี้ไม่ได้ ไดอะแกรมปัจจุบันจะยังคงเหมือนเดิม และจะแจ้งว่าต้องใช้เบราว์เซอร์รุ่นปัจจุบัน', + 'whatsNew.v0171.compatible': 'ลิงก์แชร์ที่มีอยู่ยังใช้งานร่วมกันได้ และรูปแบบลิงก์ไม่ได้เปลี่ยน', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/tr/ui.ts b/src/i18n/locales/tr/ui.ts index 8850dbe9..d6af50d2 100644 --- a/src/i18n/locales/tr/ui.ts +++ b/src/i18n/locales/tr/ui.ts @@ -567,6 +567,12 @@ const ui = { 'whatsNew.v0170.open': 'Korumalı bağlantı, paylaşılan diyagramdan herhangi bir şey gösterilmeden önce parolasını sorar. Yanlış parola ile oluşturulduktan sonra değiştirilmiş şifreli bağlantı verileri aynı mesajı verir.', 'whatsNew.v0170.limits': 'Kaybedilen parola kurtarılamaz ve bağlantı ancak parolası kadar güçlüdür. Loop Studio parolayı da anahtarı da saklamaz ve iletmez.', 'whatsNew.v0170.plain': 'Düz bağlantı hâlâ varsayılandır ve eskisi gibi çalışır.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'Bu tarayıcı paylaşım bağlantısı oluşturamaz veya açamaz. Güncel bir tarayıcıda yeniden deneyin.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'Paylaşım bağlantıları artık tarayıcının yerleşik sıkıştırmasını kullanıyor.', + 'whatsNew.v0171.unavailable': 'Bu özellik kullanılamıyorsa Loop Studio mevcut diyagramı değiştirmeden bırakır ve güncel bir tarayıcı gerektiğini açıklar.', + 'whatsNew.v0171.compatible': 'Mevcut paylaşım bağlantıları uyumlu kalıyor; bağlantı biçimi değişmedi.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/vi/ui.ts b/src/i18n/locales/vi/ui.ts index 4029ab55..df6fe895 100644 --- a/src/i18n/locales/vi/ui.ts +++ b/src/i18n/locales/vi/ui.ts @@ -513,6 +513,12 @@ const ui = { 'whatsNew.v0170.open': 'Liên kết được bảo vệ sẽ hỏi mật khẩu trước khi hiện bất cứ thứ gì của sơ đồ được chia sẻ. Mật khẩu sai và dữ liệu liên kết đã mã hóa bị thay đổi sau khi tạo cho cùng một thông báo.', 'whatsNew.v0170.limits': 'Mật khẩu đã mất thì không thể khôi phục, và liên kết chỉ an toàn bằng chính mật khẩu của nó. Loop Studio không lưu trữ và không truyền đi mật khẩu hay khóa.', 'whatsNew.v0170.plain': 'Liên kết thường vẫn là mặc định và hoạt động như trước.', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': 'Trình duyệt này không thể tạo hoặc mở liên kết chia sẻ. Hãy thử lại bằng một trình duyệt hiện hành.', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': 'Liên kết chia sẻ giờ dùng tính năng nén có sẵn của trình duyệt.', + 'whatsNew.v0171.unavailable': 'Nếu tính năng này không dùng được, Loop Studio giữ nguyên sơ đồ hiện tại và cho biết cần một trình duyệt hiện hành.', + 'whatsNew.v0171.compatible': 'Các liên kết chia sẻ hiện có vẫn tương thích; định dạng liên kết không thay đổi.', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hans/ui.ts b/src/i18n/locales/zh-Hans/ui.ts index a15eb33f..d42fd6ed 100644 --- a/src/i18n/locales/zh-Hans/ui.ts +++ b/src/i18n/locales/zh-Hans/ui.ts @@ -505,6 +505,12 @@ const ui = { 'whatsNew.v0170.open': '受保护的链接会先询问密码,之后才显示共享图表的任何内容。密码错误和创建后被改动的加密链接数据,给出的是同一条提示。', 'whatsNew.v0170.limits': '密码丢失后无法找回,链接的安全程度取决于密码。Loop Studio 不存储也不传输密码或密钥。', 'whatsNew.v0170.plain': '普通链接仍是默认选项,用法与之前相同。', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': '此浏览器无法创建或打开分享链接。请在较新的浏览器中重试。', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': '分享链接现在使用浏览器内置的压缩功能。', + 'whatsNew.v0171.unavailable': '如果无法使用此功能,当前图表将保持不变,并提示需要较新的浏览器。', + 'whatsNew.v0171.compatible': '现有的分享链接仍然兼容,链接格式也没有改变。', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hant/ui.ts b/src/i18n/locales/zh-Hant/ui.ts index e063cb15..c9386292 100644 --- a/src/i18n/locales/zh-Hant/ui.ts +++ b/src/i18n/locales/zh-Hant/ui.ts @@ -531,6 +531,12 @@ const ui = { 'whatsNew.v0170.open': '受保護的連結會先詢問密碼,之後才顯示共享圖表的任何內容。密碼錯誤和建立後被更動的加密連結資料,給出的是同一則提示。', 'whatsNew.v0170.limits': '密碼遺失後無法找回,連結的安全程度取決於密碼。Loop Studio 不保存也不傳輸密碼或金鑰。', 'whatsNew.v0170.plain': '一般連結仍是預設選項,用法與之前相同。', + // issue #301 decision 1 - no Compression Streams: no share link of either kind can be made or opened here + 'share.unavailable': '此瀏覽器無法建立或開啟分享連結。請在較新的瀏覽器中重試。', + // issue #301 decision 1 - the 0.17.1 release notes: share links use the browser's own compression + 'whatsNew.v0171.compression': '分享連結現在使用瀏覽器內建的壓縮功能。', + 'whatsNew.v0171.unavailable': '如果無法使用此功能,目前的圖表會保持不變,並提示需要較新的瀏覽器。', + 'whatsNew.v0171.compatible': '現有的分享連結仍然相容,連結格式也沒有改變。', } satisfies Record export default ui diff --git a/src/i18n/nlCopy.test.ts b/src/i18n/nlCopy.test.ts index 6bab2fb5..a7b246c9 100644 --- a/src/i18n/nlCopy.test.ts +++ b/src/i18n/nlCopy.test.ts @@ -255,15 +255,15 @@ const IDENTICAL_TO_EN: ReadonlyArray = [ // ------------------------------------------------------------------ shape describe('nl copy — the surfaces exist and are complete', () => { it('has exactly the base key set', () => { - expect(KEYS).toHaveLength(977) + expect(KEYS).toHaveLength(981) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines) expect(Object.keys(NL).sort()).toEqual([...KEYS].sort()) }) it('covers all four runtime surfaces, at the measured sizes', () => { const per: Record = {} for (const r of RUNTIME) per[r.surface.split('/')[0]!] = (per[r.surface.split('/')[0]!] ?? 0) + 1 - expect(per).toEqual({ catalog: 977, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1199) + expect(per).toEqual({ catalog: 981, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1203) }) it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => { diff --git a/src/i18n/ptPtCopy.test.ts b/src/i18n/ptPtCopy.test.ts index f533562c..08922a45 100644 --- a/src/i18n/ptPtCopy.test.ts +++ b/src/i18n/ptPtCopy.test.ts @@ -58,7 +58,7 @@ describe('pt-PT copy — the region audit over pt-BR', () => { // ---------------------------------------------------------------- shape it('has exactly the same key set as pt-BR', () => { expect(Object.keys(ptValues).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(977) + expect(KEYS).toHaveLength(981) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines) }) it('differs from pt-BR on exactly the audited keys', () => { @@ -109,7 +109,12 @@ describe('pt-PT copy — the region audit over pt-BR', () => { // release-note lines - `palavra-passe` not `senha`, `ligação` not `link`, // `cifrado` not `criptografado`, `guardar` not `salvar`, `aplicação` not // `app`, `gestor` not `gerenciador`, `a abrir` not `abrindo`. - expect(DELTA).toHaveLength(255) + // Issue #301 decision 1 moved it from 255 to 259: the one sentence for a + // browser that can make no share link and the three 0.17.1 release-note + // lines - `ligações de partilha` not `links de compartilhamento`, `num` + // not `em um`, `passam a usar` not `agora usam`, `funcionalidade` not + // `recurso`, `necessário` not `preciso`, `ligação` not `link`. + expect(DELTA).toHaveLength(259) // and it is a real audit, not a rewrite — most of the catalog agrees. // // The password strings of issue #300 are counted apart. The word for a @@ -153,7 +158,7 @@ describe('pt-PT copy — the region audit over pt-BR', () => { // outside them, the quarter bound stands as it always did const rest = KEYS.filter((k) => !isPasswordKey(k)) const restDelta = DELTA.filter((k) => !isPasswordKey(k)) - expect(restDelta).toHaveLength(227) + expect(restDelta).toHaveLength(231) expect(restDelta.length).toBeLessThan(rest.length / 4) }) diff --git a/src/i18n/ruCopy.test.ts b/src/i18n/ruCopy.test.ts index 2bfefa36..46d1fb44 100644 --- a/src/i18n/ruCopy.test.ts +++ b/src/i18n/ruCopy.test.ts @@ -43,7 +43,7 @@ describe('ru copy — the first Cyrillic catalog', () => { // ------------------------------------------------------------------ shape it('has exactly the base key set', () => { expect(Object.keys(ruValues).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(977) + expect(KEYS).toHaveLength(981) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines) }) // ----------------------------------------------------------------- script diff --git a/src/i18n/thCopy.test.ts b/src/i18n/thCopy.test.ts index 5a8cef9a..abb3dc57 100644 --- a/src/i18n/thCopy.test.ts +++ b/src/i18n/thCopy.test.ts @@ -88,11 +88,11 @@ const englishFor = (surface: string, id: string): string => { describe('th copy — the first no-space script', () => { it('has exactly the base key set', () => { expect(Object.keys(TH).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(977) + expect(KEYS).toHaveLength(981) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines) }) it('covers all three runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(977) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(981) expect(RUNTIME.filter(([s]) => s.startsWith('template/'))).toHaveLength(196) expect(RUNTIME.filter(([s]) => s.startsWith('frame/'))).toHaveLength(7) expect(RUNTIME.filter(([s]) => s.startsWith('module/'))).toHaveLength(19) diff --git a/src/i18n/trCopy.test.ts b/src/i18n/trCopy.test.ts index 9d5e2183..d6776ff4 100644 --- a/src/i18n/trCopy.test.ts +++ b/src/i18n/trCopy.test.ts @@ -30,7 +30,7 @@ const KEYS = Object.keys(EN) describe('tr copy — the first Turkish catalog', () => { it('has exactly the base key set', () => { expect(Object.keys(TR).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(977) // issue #300: +33 + expect(KEYS).toHaveLength(981) // issue #300: +33; issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines) }) }) diff --git a/src/i18n/viCopy.test.ts b/src/i18n/viCopy.test.ts index a7f68d1b..b4c5260d 100644 --- a/src/i18n/viCopy.test.ts +++ b/src/i18n/viCopy.test.ts @@ -132,11 +132,11 @@ const englishFor = (surface: string, id: string): string => { describe('vi copy — the first locale English hides inside', () => { it('has exactly the base key set', () => { expect(Object.keys(VI).sort()).toEqual(KEYS.slice().sort()) - expect(KEYS).toHaveLength(977) + expect(KEYS).toHaveLength(981) // issue #301 decision 1: +4 (share.unavailable and the three 0.17.1 release-note lines) }) it('covers all four runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(977) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(981) expect(RUNTIME.filter(([s]) => s.startsWith('template/'))).toHaveLength(196) expect(RUNTIME.filter(([s]) => s.startsWith('frame/'))).toHaveLength(7) expect(RUNTIME.filter(([s]) => s.startsWith('module/'))).toHaveLength(19) @@ -536,6 +536,8 @@ const ASCII_KEYS = [ 'template/mmo-progression:z3_xp_meter', 'catalog:whatsNew.v0153.icons', 'catalog:whatsNew.v0170.limits', + // issue #301 decision 1: `Loop Studio` in a 0.17.1 release-note line + 'catalog:whatsNew.v0171.unavailable', // the storage gate and the Storage and privacy area (issue #297) 'catalog:gate.lead', 'catalog:gate.portable.note', @@ -557,7 +559,7 @@ describe('a kept English word is declared, twice over — by key and by word', ( ([s, id]) => s + ':' + id, ) expect(actual.slice().sort()).toEqual(ASCII_KEYS.slice().sort()) - expect(ASCII_KEYS).toHaveLength(183) + expect(ASCII_KEYS).toHaveLength(184) }) it('the kept vocabulary is exactly the declared one', () => { diff --git a/src/model/share.test.ts b/src/model/share.test.ts index b47a77bb..0b5f9033 100644 --- a/src/model/share.test.ts +++ b/src/model/share.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { SHARE_MAX_BYTES, SHARE_MAX_DECODED_BYTES, @@ -10,19 +10,18 @@ import { decodeShareText, encodeShareText, fitsShareLink, - inflateZlibJs, readShareFragment, + shareCompressionAvailable, utf8Bytes, zlibDeflate, - zlibDeflateFixedJs, zlibInflate, - zlibWrapStored, } from './share' +import { LEGACY_SHARE_VECTORS } from './shareLegacy.fixture' -// Node 22 (vitest env) has Compression/DecompressionStream, so `zlibDeflate` / -// `zlibInflate` exercise the NATIVE path here. The pure-JS fallback is reached -// directly through `zlibWrapStored` / `inflateZlibJs`. The interop block below -// crosses the two so neither can drift from the other. +// Node (the vitest env) has Compression/DecompressionStream, so `zlibDeflate` / +// `zlibInflate` run the one path there is: the native one. There is no +// bundled fallback since issue #301 decision 1 (SS U1.3); links that the +// removed pure-JS encoders made are pinned as fixed vectors below. const enc = (s: string) => utf8Bytes(s) const dec = (b: Uint8Array) => new TextDecoder().decode(b) @@ -101,42 +100,27 @@ describe('base64url', () => { // -- zlib wrapper vs raw DEFLATE (SS U12.7) ----------------------------- describe('zlib wrapper distinction', () => { - it('inflateZlibJs rejects a raw DEFLATE stream as not-zlib', async () => { - const raw = await nativeDeflateRaw(enc('the quick brown fox')) - expect(() => inflateZlibJs(raw, SHARE_MAX_DECODED_BYTES)).toThrow(ShareError) - try { - inflateZlibJs(raw, SHARE_MAX_DECODED_BYTES) - } catch (e) { - expect((e as ShareError).reason).toBe('not-zlib') - } - }) - - it('inflateZlibJs rejects garbage / preset-dictionary headers as not-zlib', () => { - expect(() => inflateZlibJs(new Uint8Array([0, 0, 0, 0, 0, 0]), 1000)).toThrow( - /not-zlib/, - ) - // 0x78 0x20: FDICT bit set -> % 31 !== 0 anyway, but assert the reason - expect(() => inflateZlibJs(new Uint8Array([0x78, 0xbb, 1, 2, 3, 4]), 1000)).toThrow( - /not-zlib/, - ) - }) - - it('native DecompressionStream(deflate) also refuses a raw stream', async () => { + it('native DecompressionStream(deflate) refuses a raw stream', async () => { const raw = await nativeDeflateRaw(enc('hello hello hello')) await expect(zlibInflate(raw, SHARE_MAX_DECODED_BYTES)).rejects.toBeInstanceOf(ShareError) }) - it('zlibWrapStored produces a valid RFC 1950 header', () => { - const w = zlibWrapStored(enc('abc')) - expect(w[0]).toBe(0x78) + it('garbage and preset-dictionary headers fail as a typed ShareError', async () => { + await expect(zlibInflate(new Uint8Array([0, 0, 0, 0, 0, 0]), 1000)).rejects.toBeInstanceOf(ShareError) + await expect(zlibInflate(new Uint8Array([0x78, 0xbb, 1, 2, 3, 4]), 1000)).rejects.toBeInstanceOf(ShareError) + }) + + it('native deflate emits a zlib wrapper, not raw DEFLATE', async () => { + const w = await zlibDeflate(enc('abc')) + expect(w[0] & 0x0f).toBe(8) // CM = deflate expect(((w[0] << 8) | w[1]) % 31).toBe(0) expect(w[1] & 0x20).toBe(0) // no preset dict }) }) -// -- native <-> pure-JS interop (SS U1.3 / D2 / U12.8) ------------------ +// -- native round trip (SS U1.3) --------------------------------------- -describe('deflate/inflate interop', () => { +describe('deflate/inflate round trip', () => { const samples = [ '', 'a', @@ -146,29 +130,6 @@ describe('deflate/inflate interop', () => { 'x'.repeat(20000), ] - it('native deflate -> pure-JS inflate', async () => { - for (const s of samples) { - const packed = await zlibDeflate(enc(s)) // native CompressionStream('deflate') - const back = inflateZlibJs(packed, SHARE_MAX_DECODED_BYTES) // pure JS - expect(dec(back)).toBe(s) - } - }) - - it('pure-JS deflate (stored) -> native inflate', async () => { - for (const s of samples) { - const packed = zlibWrapStored(enc(s)) - const back = await zlibInflate(packed, SHARE_MAX_DECODED_BYTES) // native DecompressionStream - expect(dec(back)).toBe(s) - } - }) - - it('pure-JS deflate -> pure-JS inflate', () => { - for (const s of samples) { - const back = inflateZlibJs(zlibWrapStored(enc(s)), SHARE_MAX_DECODED_BYTES) - expect(dec(back)).toBe(s) - } - }) - it('native deflate -> native inflate', async () => { for (const s of samples) { const back = await zlibInflate(await zlibDeflate(enc(s)), SHARE_MAX_DECODED_BYTES) @@ -182,254 +143,82 @@ describe('deflate/inflate interop', () => { packed[6] ^= 0xff let threw: unknown try { - inflateZlibJs(packed, SHARE_MAX_DECODED_BYTES) + await zlibInflate(packed, SHARE_MAX_DECODED_BYTES) } catch (e) { threw = e } expect(threw).toBeInstanceOf(ShareError) - expect(['inflate-failed', 'not-zlib']).toContain((threw as ShareError).reason) - }) -}) - -// -- pure-JS fallback ENCODER really compresses (SS U1.3 / D2) ---------- - -// A representative repetitive GraphDoc: repeated keys and structure, the shape -// LZ77 + fixed Huffman is meant to shrink. ~40 nodes / ~40 edges. -function sampleGraph(nodes = 40): string { - return JSON.stringify({ - schema: 'loop-studio/graph', - version: 1, - nodes: Array.from({ length: nodes }, (_, i) => ({ - id: `node_${i}`, - type: i % 3 ? 'pool' : 'source', - position: { x: (i % 8) * 160, y: Math.floor(i / 8) * 120 }, - data: { kind: i % 3 ? 'pool' : 'source', label: `Node ${i}`, initial: i % 3 ? 5 : 0, capacity: 100 }, - })), - edges: Array.from({ length: nodes - 1 }, (_, i) => ({ - id: `edge_${i}`, - source: `node_${i}`, - target: `node_${i + 1}`, - sourceHandle: 'out', - targetHandle: 'in', - type: 'loop', - data: { kind: 'resource', flow: String(1 + (i % 4)) }, - })), - recommendedRunConfig: { baseSeed: 1, runs: 200, steps: 30, tracked: [] }, - }) -} - -describe('zlibDeflateFixedJs (pure-JS fallback encoder)', () => { - it('shrinks a repetitive GraphDoc well below the original', () => { - const raw = enc(sampleGraph(40)) - const packed = zlibDeflateFixedJs(raw) - expect(packed.length).toBeLessThan(raw.length * 0.6) - expect(dec(inflateZlibJs(packed, SHARE_MAX_DECODED_BYTES))).toBe(dec(raw)) - }) - - it('also shrinks highly repetitive input dramatically', () => { - const raw = enc('x'.repeat(20000)) - expect(zlibDeflateFixedJs(raw).length).toBeLessThan(400) - }) - - it('a representative large graph fits 8 KiB via BOTH native and the JS fallback', async () => { - const graph = sampleGraph(90) - const jsPayload = base64urlEncode(zlibDeflateFixedJs(enc(graph))) - const nativePayload = (await encodeShareText(graph)).payload - expect(jsPayload.length).toBeLessThanOrEqual(SHARE_MAX_BYTES) - expect(nativePayload.length).toBeLessThanOrEqual(SHARE_MAX_BYTES) - // sanity: the JS encoder is genuinely compressing, not merely storing - expect(jsPayload.length).toBeLessThan(enc(graph).length) - }) - - it('JS-made link opens with native inflate; native-made link opens with JS inflate', async () => { - const graph = sampleGraph(50) - const raw = enc(graph) - - const jsLink = zlibDeflateFixedJs(raw) - expect(dec(await zlibInflate(jsLink, SHARE_MAX_DECODED_BYTES))).toBe(graph) // native DecompressionStream - - const nativeLink = await zlibDeflate(raw) - expect(dec(inflateZlibJs(nativeLink, SHARE_MAX_DECODED_BYTES))).toBe(graph) // pure-JS inflate - }) - - it('JS encoder output is a zlib wrapper, not raw DEFLATE', () => { - const packed = zlibDeflateFixedJs(enc(sampleGraph(12))) - expect(packed[0]).toBe(0x78) - expect(((packed[0] << 8) | packed[1]) % 31).toBe(0) - expect(packed[1] & 0x20).toBe(0) // FDICT clear - // it inflates as zlib, and is NOT accepted as a raw stream - expect(() => inflateZlibJs(packed, SHARE_MAX_DECODED_BYTES)).not.toThrow() - expect(() => inflateZlibJs(packed.subarray(2), SHARE_MAX_DECODED_BYTES)).toThrow(/not-zlib/) + expect((threw as ShareError).reason).toBe('inflate-failed') }) - it('Hangul + emoji GraphDoc: JS compress -> native inflate is byte-identical', async () => { + it('a 90-node graph fits the 8 KiB cap', async () => { const graph = JSON.stringify({ schema: 'loop-studio/graph', version: 1, - nodes: [ - { id: 'a', type: 'source', position: { x: 0, y: 0 }, data: { kind: 'source', label: '수도꼭지 🚰 광석' } }, - { id: 'b', type: 'pool', position: { x: 200, y: 0 }, data: { kind: 'pool', label: '금고 🏦 инвентарь', initial: 5 } }, - ], - edges: [{ id: 'e', source: 'a', target: 'b', type: 'loop', data: { kind: 'resource', flow: '2' } }], + nodes: Array.from({ length: 90 }, (_, i) => ({ + id: `node_${i}`, + type: i % 3 ? 'pool' : 'source', + position: { x: (i % 8) * 160, y: Math.floor(i / 8) * 120 }, + data: { kind: i % 3 ? 'pool' : 'source', label: `Node ${i}`, initial: i % 3 ? 5 : 0, capacity: 100 }, + })), + edges: Array.from({ length: 89 }, (_, i) => ({ + id: `edge_${i}`, + source: `node_${i}`, + target: `node_${i + 1}`, + type: 'loop', + data: { kind: 'resource', flow: String(1 + (i % 4)) }, + })), }) - const raw = enc(graph) - const roundTripped = await zlibInflate(zlibDeflateFixedJs(raw), SHARE_MAX_DECODED_BYTES) - expect([...roundTripped]).toEqual([...raw]) // exact bytes - expect(dec(roundTripped)).toBe(graph) - }) - - it('incompressible input may grow, still round-trips, and the cap is judged on the final payload', async () => { - const rnd = new Uint8Array(4096) - crypto.getRandomValues(rnd) - let s = '' - for (const byte of rnd) s += String.fromCharCode(byte) - const raw = enc(s) - const packed = zlibDeflateFixedJs(raw) - // no correctness requirement on size for random data - only that it works - expect(dec(inflateZlibJs(packed, SHARE_MAX_DECODED_BYTES))).toBe(s) - expect(dec(await zlibInflate(packed, SHARE_MAX_DECODED_BYTES))).toBe(s) - // the size decision is on the base64url payload, whatever compression did - const payload = base64urlEncode(packed) - expect(fitsShareLink(payload.length)).toBe(payload.length <= SHARE_MAX_BYTES) - }) - - it('empty and 1-byte inputs still produce a valid stream', async () => { - for (const s of ['', 'Z']) { - const packed = zlibDeflateFixedJs(enc(s)) - expect(dec(inflateZlibJs(packed, SHARE_MAX_DECODED_BYTES))).toBe(s) - expect(dec(await zlibInflate(packed, SHARE_MAX_DECODED_BYTES))).toBe(s) - } + const { bytes } = await encodeShareText(graph) + expect(fitsShareLink(bytes)).toBe(true) }) }) -// -- compressor safety net: differential fuzz + explicit boundaries ----- -// The hand-rolled LZ77 + fixed-Huffman path is where an off-by-one hides. -// For every input: JS compress -> JS inflate AND JS compress -> native inflate -// must both be byte-identical to the original. - -const CAP = 1 << 20 - -function lcg(seed: number): () => number { - let s = seed >>> 0 - return () => { - s = (Math.imul(s, 1664525) + 1013904223) >>> 0 - return s - } -} -function concat(...parts: Uint8Array[]): Uint8Array { - const n = parts.reduce((a, p) => a + p.length, 0) - const out = new Uint8Array(n) - let o = 0 - for (const p of parts) { - out.set(p, o) - o += p.length - } - return out -} -function eqBytes(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false - return true -} -async function bothInflate(raw: Uint8Array): Promise { - const packed = zlibDeflateFixedJs(raw) - expect(eqBytes(inflateZlibJs(packed, CAP), raw)).toBe(true) - expect(eqBytes(await zlibInflate(packed, CAP), raw)).toBe(true) -} +// -- links the removed pure-JS encoders made (issue #301 decision 1) ------ +// The portable build and browsers without CompressionStream used to make `g1` +// links with a bundled LZ77 + fixed-Huffman encoder. Those links are still in +// people's messages and notes: each must keep opening, byte for byte, through +// the native path. The payloads were made by the encoders as they shipped on +// main 938aae3 and are fixed data. -describe('zlibDeflateFixedJs - differential fuzz + boundary cases', () => { - const rnd = lcg(0xc0ffee) - const randBytes = (n: number): Uint8Array => { - const a = new Uint8Array(n) - for (let i = 0; i < n; i++) a[i] = rnd() & 0xff - return a - } - const patterns: Record Uint8Array> = { - zeros: (n) => new Uint8Array(n), - random: randBytes, - lowEntropy: (n) => { - const a = new Uint8Array(n) - for (let i = 0; i < n; i++) a[i] = i % 7 - return a - }, - block13: (n) => { - const blk = randBytes(13) - const a = new Uint8Array(n) - for (let i = 0; i < n; i++) a[i] = blk[i % 13] - return a - }, - textish: (n) => { - const A = '{}[]",: abcdefghij0123\n' - const a = new Uint8Array(n) - for (let i = 0; i < n; i++) a[i] = A.charCodeAt((i * 7 + (i >> 4)) % A.length) - return a - }, - } - const sizes = [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 128, 129, 255, 256, 257, - 258, 259, 260, 300, 511, 512, 513, 1000, 4095, 4096, 4097, - ] +describe('links made by the removed pure-JS encoders', () => { + it('has the vectors it is meant to have', () => { + expect(LEGACY_SHARE_VECTORS.map((v) => v.encoder)).toEqual(['fixed', 'fixed', 'fixed', 'fixed', 'fixed', 'stored', 'fixed']) + }) - for (const name of Object.keys(patterns)) { - it(`byte-identical round-trip: ${name} at ${sizes.length} lengths (JS->JS and JS->native)`, async () => { - for (const n of sizes) await bothInflate(patterns[name](n)) + for (const v of LEGACY_SHARE_VECTORS) { + it(`opens through the native inflater: ${v.name}`, async () => { + expect(base64urlDecode(v.payload)[0]).toBe(0x78) // a zlib wrapper + expect(await decodeShareText(v.payload)).toBe(v.text) }) } +}) - it('exact match LENGTHS 3 / 257 / 258 / 259 / 400 round-trip', async () => { - for (const L of [3, 257, 258, 259, 400]) { - const seg = randBytes(L) - // seg followed by a different byte in each copy -> the match is exactly L - await bothInflate(concat(seg, Uint8Array.of(0x00), seg, Uint8Array.of(0x01))) - } - }) +// -- no Compression Streams: no link either way (SS U1.3) ---------------- - it('exact match DISTANCES 1 / 32767 / 32768 round-trip', async () => { - await bothInflate(concat(Uint8Array.of(0x41), new Uint8Array(600).fill(0x41))) // dist 1 - for (const D of [32767, 32768]) { - const A = randBytes(D) - await bothInflate(concat(A, A)) // 2nd copy starts at offset D - } +describe('a page without Compression Streams', () => { + afterEach(() => { + vi.unstubAllGlobals() }) - it('the INFLATER accepts a native stream that uses distance 32768', async () => { - const P = patterns.block13(32768) - // 2nd P starts at offset 32769; the 1st P starts at offset 1 -> distance 32768 - const raw = concat(Uint8Array.of(0x00), P, P) - const nativePacked = await zlibDeflate(raw) - expect(eqBytes(inflateZlibJs(nativePacked, CAP), raw)).toBe(true) + it('is detected when asked, not when the module loaded', () => { + expect(shareCompressionAvailable()).toBe(true) + vi.stubGlobal('DecompressionStream', undefined) + expect(shareCompressionAvailable()).toBe(false) + vi.unstubAllGlobals() + vi.stubGlobal('CompressionStream', undefined) + expect(shareCompressionAvailable()).toBe(false) }) - it('input ending mid-match / at odd byte boundaries round-trips', async () => { - const base = patterns.textish(777) - for (const cut of [1, 2, 3, 4, 5, 257, 258, 259, 511, 512, 513, 776, 777]) { - // the trailing region is a prefix of `base` -> the tail match runs off the end - await bothInflate(concat(base, base.subarray(0, cut))) - } + it('cannot encode: a typed ShareError, unavailable', async () => { + vi.stubGlobal('CompressionStream', undefined) + await expect(encodeShareText('{"nodes":[],"edges":[]}')).rejects.toMatchObject({ name: 'ShareError', reason: 'unavailable' }) }) - it('mixed random + repeated regions, many seeds', async () => { - for (let seed = 1; seed <= 12; seed++) { - const g = lcg(seed * 2654435761) - const chunks: Uint8Array[] = [] - const count = 3 + (g() % 6) - for (let k = 0; k < count; k++) { - const kind = g() % 3 - const len = 1 + (g() % 900) - if (kind === 0) { - const c = new Uint8Array(len) - for (let i = 0; i < len; i++) c[i] = g() & 0xff - chunks.push(c) - } else if (kind === 1) { - chunks.push(new Uint8Array(len).fill(g() & 0xff)) - } else if (chunks.length) { - chunks.push(chunks[g() % chunks.length].slice()) // repeat an earlier region - } else { - chunks.push(new Uint8Array(len).fill(0x2a)) - } - } - await bothInflate(concat(...chunks)) - } + it('cannot decode, even a well-formed link: a typed ShareError, unavailable', async () => { + const { payload } = await encodeShareText('{"nodes":[],"edges":[]}') + vi.stubGlobal('DecompressionStream', undefined) + await expect(decodeShareText(payload)).rejects.toMatchObject({ name: 'ShareError', reason: 'unavailable' }) }) }) @@ -482,18 +271,6 @@ describe('SHARE_MAX_DECODED_BYTES (inbound, 1 MiB, incremental)', () => { expect((threw as ShareError).reason).toBe('decoded-too-large') }) - it('pure-JS path: output is bounded and never fully built', () => { - const stored = zlibWrapStored(new Uint8Array(500)) // 500 zero bytes, stored - let threw: unknown - try { - inflateZlibJs(stored, 100) // tiny cap - } catch (e) { - threw = e - } - expect(threw).toBeInstanceOf(ShareError) - expect((threw as ShareError).reason).toBe('decoded-too-large') - }) - it('decodeShareText rejects a bomb with a typed error and returns nothing', async () => { const bomb = await zlibDeflate(new Uint8Array(3 * 1024 * 1024)) const payload = base64urlEncode(bomb) diff --git a/src/model/share.ts b/src/model/share.ts index 38fba7a1..e18a533a 100644 --- a/src/model/share.ts +++ b/src/model/share.ts @@ -2,13 +2,10 @@ // // Step 1: no store, no UI. This module owns the wire transport only: // - strict base64url (SS U1.4) -// - zlib-wrapped DEFLATE (RFC 1950), with a Web-native path -// (Compression/DecompressionStream) and a self-contained fallback so a -// portable file:// build can both PRODUCE and OPEN a link (SS U1.3). The -// fallback encoder is a real DEFLATE compressor - LZ77 + fixed Huffman -// (`zlibDeflateFixedJs`) - so a repetitive graph shrinks on file:// too, -// not just on https. (`zlibWrapStored` - uncompressed - is kept only as an -// inflater test vector.) +// - zlib-wrapped DEFLATE (RFC 1950) through the browser's own +// Compression Streams, in both directions. There is no bundled fallback: +// a page without them can neither make nor open a link, and says so +// (SS U1.3, issue #301 decision 1). // - the outbound SHARE_MAX_BYTES check (SS U3.1) // - the inbound, INCREMENTAL SHARE_MAX_DECODED_BYTES decompression-bomb guard // that aborts before any parse and materialises nothing beyond the cap @@ -35,6 +32,7 @@ export type ShareFailure = | 'not-zlib' // missing / invalid RFC 1950 header, or a raw DEFLATE stream | 'inflate-failed' // corrupt DEFLATE body, bad checksum, truncated | 'decoded-too-large' // inflate output would exceed SHARE_MAX_DECODED_BYTES + | 'unavailable' // the page has no Compression Streams (SS U1.3) export class ShareError extends Error { reason: ShareFailure @@ -86,487 +84,7 @@ export function base64urlDecode(payload: string): Uint8Array { return out } -// -- zlib (RFC 1950) wrapper ---------------------------------------------- - -function adler32(data: Uint8Array): number { - let a = 1 - let b = 0 - for (let i = 0; i < data.length; i++) { - a = (a + data[i]) % 65521 - b = (b + a) % 65521 - } - return ((b << 16) | a) >>> 0 -} - -/** - * A valid RFC 1950 stream built from RFC 1951 *stored* (uncompressed) blocks. - * NOT used as the encode fallback (that is `zlibDeflateFixedJs`, which actually - * compresses) - kept only as a decoder test vector: any conformant inflater - * must read it. Cross-implementation byte differences are expected (SS U1.3 / D2). - */ -export function zlibWrapStored(data: Uint8Array): Uint8Array { - // Canonical empty zlib stream - `DecompressionStream('deflate')` rejects an - // empty *stored* block, and accepts this. (A share payload is never empty; - // this only keeps the codec total.) - if (data.length === 0) return new Uint8Array([0x78, 0x9c, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]) - - const body: number[] = [] - let off = 0 - do { - const len = Math.min(0xffff, data.length - off) - const last = off + len >= data.length ? 1 : 0 - // BFINAL in bit 0, BTYPE=00 -> the header byte is just BFINAL; then - // LEN (LE u16), NLEN (~LEN, LE u16), then the raw bytes. - body.push(last, len & 0xff, (len >> 8) & 0xff, ~len & 0xff, (~len >> 8) & 0xff) - for (let i = 0; i < len; i++) body.push(data[off + i]) - off += len - } while (off < data.length) - - const adler = adler32(data) - const out = new Uint8Array(2 + body.length + 4) - out[0] = 0x78 // CM=8, CINFO=7 - out[1] = 0x01 // FLEVEL=0, FDICT=0, FCHECK makes 0x7801 % 31 === 0 - out.set(body, 2) - const p = 2 + body.length - out[p] = (adler >>> 24) & 0xff - out[p + 1] = (adler >>> 16) & 0xff - out[p + 2] = (adler >>> 8) & 0xff - out[p + 3] = adler & 0xff - return out -} - -// -- pure-JS inflate (RFC 1951) ------------------------------------------ - -// A compact inflate: stored, fixed-Huffman, and dynamic-Huffman blocks, with -// the output length bounded by `maxOut` (checked as the window grows, so a bomb -// never allocates past the cap). Adapted from the well-known "tiny inflate" -// algorithm (public domain). - -const LEN_BASE = [ - 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, - 163, 195, 227, 258, -] -const LEN_EXTRA = [ - 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, -] -const DIST_BASE = [ - 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, - 3073, 4097, 6145, 8193, 12289, 16385, 24577, -] -const DIST_EXTRA = [ - 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, -] -const CLEN_ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15] - -type Huff = { counts: Uint16Array; symbols: Uint16Array } - -function buildHuff(lengths: Uint8Array, offset: number, count: number): Huff { - const counts = new Uint16Array(16) - const symbols = new Uint16Array(count) - for (let i = 0; i < count; i++) counts[lengths[offset + i]]++ - counts[0] = 0 - const offs = new Uint16Array(16) - for (let i = 1; i < 16; i++) offs[i] = offs[i - 1] + counts[i - 1] - for (let i = 0; i < count; i++) { - const l = lengths[offset + i] - if (l) symbols[offs[l]++] = i - } - return { counts, symbols } -} - -class BitReader { - private buf: Uint8Array - private pos: number - private bitBuf = 0 - private bitCnt = 0 - constructor(buf: Uint8Array, start: number) { - this.buf = buf - this.pos = start - } - bits(n: number): number { - while (this.bitCnt < n) { - if (this.pos >= this.buf.length) throw new ShareError('inflate-failed') - this.bitBuf |= this.buf[this.pos++] << this.bitCnt - this.bitCnt += 8 - } - const v = this.bitBuf & ((1 << n) - 1) - this.bitBuf >>>= n - this.bitCnt -= n - return v - } - alignByte(): void { - this.bitBuf = 0 - this.bitCnt = 0 - } - readBytes(n: number): Uint8Array { - if (this.pos + n > this.buf.length) throw new ShareError('inflate-failed') - const out = this.buf.subarray(this.pos, this.pos + n) - this.pos += n - return out - } - decode(h: Huff): number { - let code = 0 - let first = 0 - let index = 0 - for (let len = 1; len < 16; len++) { - code |= this.bits(1) - const c = h.counts[len] - if (code - first < c) return h.symbols[index + (code - first)] - index += c - first = (first + c) << 1 - code <<= 1 - } - throw new ShareError('inflate-failed') - } -} - -class Output { - private chunks: Uint8Array[] = [] - private cur = new Uint8Array(0x8000) - private curLen = 0 - total = 0 - private max: number - constructor(max: number) { - this.max = max - } - private push(byte: number) { - if (this.total + 1 > this.max) throw new ShareError('decoded-too-large') - if (this.curLen === this.cur.length) { - this.chunks.push(this.cur) - this.cur = new Uint8Array(0x8000) - this.curLen = 0 - } - this.cur[this.curLen++] = byte - this.total++ - } - literal(byte: number) { - this.push(byte) - } - match(dist: number, len: number) { - if (dist > this.total) throw new ShareError('inflate-failed') - if (this.total + len > this.max) throw new ShareError('decoded-too-large') - for (let i = 0; i < len; i++) this.push(this.at(this.total - dist)) - } - copyRaw(src: Uint8Array) { - if (this.total + src.length > this.max) throw new ShareError('decoded-too-large') - for (let i = 0; i < src.length; i++) this.push(src[i]) - } - private at(idx: number): number { - // idx is an absolute output index < this.total - const chunkIdx = Math.floor(idx / 0x8000) - const inChunk = idx % 0x8000 - if (chunkIdx < this.chunks.length) return this.chunks[chunkIdx][inChunk] - return this.cur[inChunk] - } - finish(): Uint8Array { - const out = new Uint8Array(this.total) - let o = 0 - for (const c of this.chunks) { - out.set(c, o) - o += c.length - } - out.set(this.cur.subarray(0, this.curLen), o) - return out - } -} - -const FIXED_LIT = (() => { - const l = new Uint8Array(288) - l.fill(8, 0, 144) - l.fill(9, 144, 256) - l.fill(7, 256, 280) - l.fill(8, 280, 288) - return buildHuff(l, 0, 288) -})() -const FIXED_DIST = (() => { - const l = new Uint8Array(30) - l.fill(5) - return buildHuff(l, 0, 30) -})() - -function inflateBlockDynamic(br: BitReader): { lit: Huff; dist: Huff } { - const hlit = br.bits(5) + 257 - const hdist = br.bits(5) + 1 - const hclen = br.bits(4) + 4 - const clen = new Uint8Array(19) - for (let i = 0; i < hclen; i++) clen[CLEN_ORDER[i]] = br.bits(3) - const clHuff = buildHuff(clen, 0, 19) - const lengths = new Uint8Array(hlit + hdist) - let n = 0 - while (n < hlit + hdist) { - const sym = br.decode(clHuff) - if (sym < 16) { - lengths[n++] = sym - } else if (sym === 16) { - const prev = n > 0 ? lengths[n - 1] : 0 - let r = br.bits(2) + 3 - while (r-- && n < lengths.length) lengths[n++] = prev - } else if (sym === 17) { - let r = br.bits(3) + 3 - while (r-- && n < lengths.length) lengths[n++] = 0 - } else { - let r = br.bits(7) + 11 - while (r-- && n < lengths.length) lengths[n++] = 0 - } - } - return { - lit: buildHuff(lengths, 0, hlit), - dist: buildHuff(lengths, hlit, hdist), - } -} - -function inflateHuffBlock(br: BitReader, out: Output, lit: Huff, dist: Huff): void { - for (;;) { - const sym = br.decode(lit) - if (sym === 256) return - if (sym < 256) { - out.literal(sym) - continue - } - const li = sym - 257 - if (li >= LEN_BASE.length) throw new ShareError('inflate-failed') - const len = LEN_BASE[li] + br.bits(LEN_EXTRA[li]) - const ds = br.decode(dist) - if (ds >= DIST_BASE.length) throw new ShareError('inflate-failed') - const d = DIST_BASE[ds] + br.bits(DIST_EXTRA[ds]) - out.match(d, len) - } -} - -/** RFC 1951 raw DEFLATE -> bytes, output length bounded by `maxOut`. */ -export function inflateRawJs(data: Uint8Array, start: number, maxOut: number): Uint8Array { - const br = new BitReader(data, start) - const out = new Output(maxOut) - let final = 0 - do { - final = br.bits(1) - const type = br.bits(2) - if (type === 0) { - br.alignByte() - const lenBytes = br.readBytes(4) - const len = lenBytes[0] | (lenBytes[1] << 8) - const nlen = lenBytes[2] | (lenBytes[3] << 8) - if ((len ^ 0xffff) !== nlen) throw new ShareError('inflate-failed') - out.copyRaw(br.readBytes(len)) - } else if (type === 1) { - inflateHuffBlock(br, out, FIXED_LIT, FIXED_DIST) - } else if (type === 2) { - const { lit, dist } = inflateBlockDynamic(br) - inflateHuffBlock(br, out, lit, dist) - } else { - throw new ShareError('inflate-failed') - } - } while (!final) - return out.finish() -} - -/** - * RFC 1950 wrapper check + `inflateRawJs` + Adler-32 verify. Rejects a raw - * DEFLATE stream (no zlib header) and a preset-dictionary stream with - * `ShareError('not-zlib')` (SS U12.7). - */ -export function inflateZlibJs(data: Uint8Array, maxOut: number): Uint8Array { - if (data.length < 6) throw new ShareError('not-zlib') - const cmf = data[0] - const flg = data[1] - const cm = cmf & 0x0f - const cinfo = cmf >> 4 - if (cm !== 8 || cinfo > 7 || ((cmf << 8) | flg) % 31 !== 0 || (flg & 0x20) !== 0) { - throw new ShareError('not-zlib') - } - const body = inflateRawJs(data, 2, maxOut) - const want = - ((data[data.length - 4] << 24) | - (data[data.length - 3] << 16) | - (data[data.length - 2] << 8) | - data[data.length - 1]) >>> - 0 - if (adler32(body) !== want) throw new ShareError('inflate-failed') - return body -} - -// -- pure-JS deflate (RFC 1951, LZ77 + fixed Huffman) ------------------ - -// The encode fallback for portable file:// and any browser without -// CompressionStream. One BFINAL fixed-Huffman block: LZ77 hash-chain matching -// feeds the RFC 1951 fixed literal/length + distance code tables, so repetitive -// data (JSON keys, repeated structure) genuinely shrinks. Not byte-identical to -// `CompressionStream('deflate')` - that is allowed (SS U1.3 / D2) - but it is a -// valid zlib stream every conformant inflater reads. - -const MIN_MATCH = 3 -const MAX_MATCH = 258 -const DEFLATE_WSIZE = 32768 -const HASH_SIZE = 1 << 15 -const MAX_CHAIN = 128 - -function rev(code: number, n: number): number { - let r = 0 - for (let k = 0; k < n; k++) { - r = (r << 1) | (code & 1) - code >>= 1 - } - return r -} - -// fixed literal/length codes (RFC 1951 3.2.6), pre-reversed for LSB-first output -const FIX_LIT_CODE = new Uint16Array(288) -const FIX_LIT_LEN = new Uint8Array(288) -for (let s = 0; s < 288; s++) { - let n: number - let c: number - if (s < 144) { - n = 8 - c = 0x30 + s - } else if (s < 256) { - n = 9 - c = 0x190 + (s - 144) - } else if (s < 280) { - n = 7 - c = s - 256 - } else { - n = 8 - c = 0xc0 + (s - 280) - } - FIX_LIT_LEN[s] = n - FIX_LIT_CODE[s] = rev(c, n) -} -const FIX_DIST_CODE = new Uint16Array(30) -for (let d = 0; d < 30; d++) FIX_DIST_CODE[d] = rev(d, 5) - -// length 3..258 -> code index 0..28 (symbol = 257 + index) -const LEN_CODE = new Uint16Array(259) -{ - let code = 0 - for (let l = 3; l <= 258; l++) { - if (code < 28 && l >= LEN_BASE[code + 1]) code++ - LEN_CODE[l] = code - } -} -// distance 1..32768 -> code index 0..29 -const DIST_CODE = new Uint8Array(DEFLATE_WSIZE + 1) -{ - let code = 0 - for (let d = 1; d <= DEFLATE_WSIZE; d++) { - if (code < 29 && d >= DIST_BASE[code + 1]) code++ - DIST_CODE[d] = code - } -} - -class BitWriter { - bytes: number[] = [] - private buf = 0 - private cnt = 0 - /** `code`'s low `n` bits, LSB first (Huffman codes must be pre-reversed). */ - write(code: number, n: number): void { - this.buf |= (code & ((1 << n) - 1)) << this.cnt - this.cnt += n - while (this.cnt >= 8) { - this.bytes.push(this.buf & 0xff) - this.buf >>>= 8 - this.cnt -= 8 - } - } - finish(): number[] { - if (this.cnt > 0) { - this.bytes.push(this.buf & 0xff) - this.buf = 0 - this.cnt = 0 - } - return this.bytes - } -} - -function hash3(a: number, b: number, c: number): number { - return ((a << 10) ^ (b << 5) ^ c) & (HASH_SIZE - 1) -} - -/** RFC 1951 stream: one final fixed-Huffman block. */ -function deflateFixed(data: Uint8Array): number[] { - const bw = new BitWriter() - bw.write(1, 1) // BFINAL = 1 - bw.write(1, 2) // BTYPE = 01 (fixed Huffman) - - const n = data.length - const head = new Int32Array(HASH_SIZE).fill(-1) - const prev = new Int32Array(DEFLATE_WSIZE).fill(-1) - - const insert = (pos: number): void => { - if (pos + MIN_MATCH > n) return - const h = hash3(data[pos], data[pos + 1], data[pos + 2]) - prev[pos & (DEFLATE_WSIZE - 1)] = head[h] - head[h] = pos - } - const emitLiteral = (byte: number): void => { - bw.write(FIX_LIT_CODE[byte], FIX_LIT_LEN[byte]) - } - const emitMatch = (len: number, dist: number): void => { - const lc = LEN_CODE[len] - const sym = 257 + lc - bw.write(FIX_LIT_CODE[sym], FIX_LIT_LEN[sym]) - if (LEN_EXTRA[lc]) bw.write(len - LEN_BASE[lc], LEN_EXTRA[lc]) - const dc = DIST_CODE[dist] - bw.write(FIX_DIST_CODE[dc], 5) - if (DIST_EXTRA[dc]) bw.write(dist - DIST_BASE[dc], DIST_EXTRA[dc]) - } - - let i = 0 - while (i < n) { - let bestLen = 0 - let bestDist = 0 - if (i + MIN_MATCH <= n) { - const h = hash3(data[i], data[i + 1], data[i + 2]) - let cand = head[h] - let chain = MAX_CHAIN - const limit = i - DEFLATE_WSIZE - const maxl = Math.min(MAX_MATCH, n - i) - while (cand >= 0 && cand > limit && chain-- > 0) { - if (data[cand + bestLen] === data[i + bestLen]) { - let l = 0 - while (l < maxl && data[cand + l] === data[i + l]) l++ - if (l > bestLen) { - bestLen = l - bestDist = i - cand - if (l >= maxl) break - } - } - cand = prev[cand & (DEFLATE_WSIZE - 1)] - } - } - if (bestLen >= MIN_MATCH) { - emitMatch(bestLen, bestDist) - const end = i + bestLen - while (i < end) { - insert(i) - i++ - } - } else { - emitLiteral(data[i]) - insert(i) - i++ - } - } - bw.write(FIX_LIT_CODE[256], FIX_LIT_LEN[256]) // end of block - return bw.finish() -} - -/** zlib-wrapped (RFC 1950) fixed-Huffman DEFLATE of `data`. */ -export function zlibDeflateFixedJs(data: Uint8Array): Uint8Array { - const body = deflateFixed(data) - const adler = adler32(data) - const out = new Uint8Array(2 + body.length + 4) - out[0] = 0x78 // CM=8, CINFO=7 - out[1] = 0x9c // FLEVEL=2, FDICT=0; 0x789c % 31 === 0 - out.set(body, 2) - const p = 2 + body.length - out[p] = (adler >>> 24) & 0xff - out[p + 1] = (adler >>> 16) & 0xff - out[p + 2] = (adler >>> 8) & 0xff - out[p + 3] = adler & 0xff - return out -} - -// -- native-or-fallback deflate / inflate -------------------------------- +// -- native deflate / inflate (Compression Streams) ----------------------- async function streamThrough( ts: CompressionStream | DecompressionStream, @@ -606,29 +124,35 @@ async function streamThrough( return out } -const hasCompressionStream = typeof CompressionStream !== 'undefined' -const hasDecompressionStream = typeof DecompressionStream !== 'undefined' +/** + * Can this page make and open share links at all? Both directions use the + * browser's own `CompressionStream` / `DecompressionStream` ('deflate', the + * zlib wrapper) and there is no bundled fallback (SS U1.3). Read at call time, + * not at module load, so a page that lacks them is judged when it acts. + */ +export function shareCompressionAvailable(): boolean { + return typeof CompressionStream !== 'undefined' && typeof DecompressionStream !== 'undefined' +} -/** zlib-wrapped DEFLATE: `CompressionStream('deflate')` when present, else the - * self-contained LZ77 + fixed-Huffman compressor (SS U1.3). */ +/** zlib-wrapped DEFLATE through `CompressionStream('deflate')` (SS U1.3). + * `ShareError('unavailable')` where the page has no Compression Streams. */ export async function zlibDeflate(bytes: Uint8Array): Promise { - if (hasCompressionStream) return streamThrough(new CompressionStream('deflate'), bytes) - return zlibDeflateFixedJs(bytes) + if (!shareCompressionAvailable()) throw new ShareError('unavailable') + return streamThrough(new CompressionStream('deflate'), bytes) } -/** Inflate a zlib stream with the output bounded by `maxOut`. Native - * `DecompressionStream` when present (incremental cap enforced as chunks - * arrive), else `inflateZlibJs`. Always a typed `ShareError` on failure. */ +/** Inflate a zlib stream with the output bounded by `maxOut`, through + * `DecompressionStream('deflate')`; the cap is enforced as chunks arrive. + * Always a typed `ShareError` on failure, `unavailable` where the page has no + * Compression Streams. */ export async function zlibInflate(bytes: Uint8Array, maxOut: number): Promise { - if (hasDecompressionStream) { - try { - return await streamThrough(new DecompressionStream('deflate'), bytes, maxOut) - } catch (e) { - if (e instanceof ShareError) throw e - throw new ShareError('inflate-failed') - } + if (!shareCompressionAvailable()) throw new ShareError('unavailable') + try { + return await streamThrough(new DecompressionStream('deflate'), bytes, maxOut) + } catch (e) { + if (e instanceof ShareError) throw e + throw new ShareError('inflate-failed') } - return inflateZlibJs(bytes, maxOut) } // -- top-level codec ---------------------------------------------------- diff --git a/src/model/shareLegacy.fixture.ts b/src/model/shareLegacy.fixture.ts new file mode 100644 index 00000000..f76a43f1 --- /dev/null +++ b/src/model/shareLegacy.fixture.ts @@ -0,0 +1,53 @@ +// Fixed `g1` payloads made by the pure-JS zlib encoders as they shipped on +// main 938aae3 (`zlibDeflateFixedJs`, and `zlibWrapStored` for stored +// blocks), before issue #301 decision 1 removed them. Links made that way - +// by the portable build or a browser without CompressionStream - must keep +// opening through the native DecompressionStream path (SEMANTICS-U.md SS U1.3). +// Generated data; do not edit by hand. + +export type LegacyShareVector = { name: string; encoder: 'fixed' | 'stored'; text: string; payload: string } + +export const LEGACY_SHARE_VECTORS: readonly LegacyShareVector[] = [ + { + "name": "graph document, 40 nodes, Hangul / CJK / emoji / Cyrillic / Arabic labels", + "encoder": "fixed", + "text": "{\"schema\":\"loop-studio/graph\",\"version\":1,\"nodes\":[{\"id\":\"node_0\",\"type\":\"source\",\"position\":{\"x\":0,\"y\":0},\"data\":{\"kind\":\"source\",\"label\":\"골드 0\",\"initial\":0}},{\"id\":\"node_1\",\"type\":\"pool\",\"position\":{\"x\":160,\"y\":0},\"data\":{\"kind\":\"pool\",\"label\":\"金庫 1\",\"initial\":5}},{\"id\":\"node_2\",\"type\":\"pool\",\"position\":{\"x\":320,\"y\":0},\"data\":{\"kind\":\"pool\",\"label\":\"🚀 launch 2\",\"initial\":5}},{\"id\":\"node_3\",\"type\":\"source\",\"position\":{\"x\":480,\"y\":0},\"data\":{\"kind\":\"source\",\"label\":\"Pool 3\",\"initial\":0}},{\"id\":\"node_4\",\"type\":\"pool\",\"position\":{\"x\":640,\"y\":0},\"data\":{\"kind\":\"pool\",\"label\":\"Ресурс 4\",\"initial\":5}},{\"id\":\"node_5\",\"type\":\"pool\",\"position\":{\"x\":800,\"y\":0},\"data\":{\"kind\":\"pool\",\"label\":\"مورد 5\",\"initial\":5}},{\"id\":\"node_6\",\"type\":\"source\",\"position\":{\"x\":960,\"y\":0},\"data\":{\"kind\":\"source\",\"label\":\"골드 6\",\"initial\":0}},{\"id\":\"node_7\",\"type\":\"pool\",\"position\":{\"x\":1120,\"y\":0},\"data\":{\"kind\":\"pool\",\"label\":\"金庫 7\",\"initial\":5}},{\"id\":\"node_8\",\"type\":\"pool\",\"position\":{\"x\":0,\"y\":120},\"data\":{\"kind\":\"pool\",\"label\":\"🚀 launch 8\",\"initial\":5}},{\"id\":\"node_9\",\"type\":\"source\",\"position\":{\"x\":160,\"y\":120},\"data\":{\"kind\":\"source\",\"label\":\"Pool 9\",\"initial\":0}},{\"id\":\"node_10\",\"type\":\"pool\",\"position\":{\"x\":320,\"y\":120},\"data\":{\"kind\":\"pool\",\"label\":\"Ресурс 10\",\"initial\":5}},{\"id\":\"node_11\",\"type\":\"pool\",\"position\":{\"x\":480,\"y\":120},\"data\":{\"kind\":\"pool\",\"label\":\"مورد 11\",\"initial\":5}},{\"id\":\"node_12\",\"type\":\"source\",\"position\":{\"x\":640,\"y\":120},\"data\":{\"kind\":\"source\",\"label\":\"골드 12\",\"initial\":0}},{\"id\":\"node_13\",\"type\":\"pool\",\"position\":{\"x\":800,\"y\":120},\"data\":{\"kind\":\"pool\",\"label\":\"金庫 13\",\"initial\":5}},{\"id\":\"node_14\",\"type\":\"pool\",\"position\":{\"x\":960,\"y\":120},\"data\":{\"kind\":\"pool\",\"label\":\"🚀 launch 14\",\"initial\":5}},{\"id\":\"node_15\",\"type\":\"source\",\"position\":{\"x\":1120,\"y\":120},\"data\":{\"kind\":\"source\",\"label\":\"Pool 15\",\"initial\":0}},{\"id\":\"node_16\",\"type\":\"pool\",\"position\":{\"x\":0,\"y\":240},\"data\":{\"kind\":\"pool\",\"label\":\"Ресурс 16\",\"initial\":5}},{\"id\":\"node_17\",\"type\":\"pool\",\"position\":{\"x\":160,\"y\":240},\"data\":{\"kind\":\"pool\",\"label\":\"مورد 17\",\"initial\":5}},{\"id\":\"node_18\",\"type\":\"source\",\"position\":{\"x\":320,\"y\":240},\"data\":{\"kind\":\"source\",\"label\":\"골드 18\",\"initial\":0}},{\"id\":\"node_19\",\"type\":\"pool\",\"position\":{\"x\":480,\"y\":240},\"data\":{\"kind\":\"pool\",\"label\":\"金庫 19\",\"initial\":5}},{\"id\":\"node_20\",\"type\":\"pool\",\"position\":{\"x\":640,\"y\":240},\"data\":{\"kind\":\"pool\",\"label\":\"🚀 launch 20\",\"initial\":5}},{\"id\":\"node_21\",\"type\":\"source\",\"position\":{\"x\":800,\"y\":240},\"data\":{\"kind\":\"source\",\"label\":\"Pool 21\",\"initial\":0}},{\"id\":\"node_22\",\"type\":\"pool\",\"position\":{\"x\":960,\"y\":240},\"data\":{\"kind\":\"pool\",\"label\":\"Ресурс 22\",\"initial\":5}},{\"id\":\"node_23\",\"type\":\"pool\",\"position\":{\"x\":1120,\"y\":240},\"data\":{\"kind\":\"pool\",\"label\":\"مورد 23\",\"initial\":5}},{\"id\":\"node_24\",\"type\":\"source\",\"position\":{\"x\":0,\"y\":360},\"data\":{\"kind\":\"source\",\"label\":\"골드 24\",\"initial\":0}},{\"id\":\"node_25\",\"type\":\"pool\",\"position\":{\"x\":160,\"y\":360},\"data\":{\"kind\":\"pool\",\"label\":\"金庫 25\",\"initial\":5}},{\"id\":\"node_26\",\"type\":\"pool\",\"position\":{\"x\":320,\"y\":360},\"data\":{\"kind\":\"pool\",\"label\":\"🚀 launch 26\",\"initial\":5}},{\"id\":\"node_27\",\"type\":\"source\",\"position\":{\"x\":480,\"y\":360},\"data\":{\"kind\":\"source\",\"label\":\"Pool 27\",\"initial\":0}},{\"id\":\"node_28\",\"type\":\"pool\",\"position\":{\"x\":640,\"y\":360},\"data\":{\"kind\":\"pool\",\"label\":\"Ресурс 28\",\"initial\":5}},{\"id\":\"node_29\",\"type\":\"pool\",\"position\":{\"x\":800,\"y\":360},\"data\":{\"kind\":\"pool\",\"label\":\"مورد 29\",\"initial\":5}},{\"id\":\"node_30\",\"type\":\"source\",\"position\":{\"x\":960,\"y\":360},\"data\":{\"kind\":\"source\",\"label\":\"골드 30\",\"initial\":0}},{\"id\":\"node_31\",\"type\":\"pool\",\"position\":{\"x\":1120,\"y\":360},\"data\":{\"kind\":\"pool\",\"label\":\"金庫 31\",\"initial\":5}},{\"id\":\"node_32\",\"type\":\"pool\",\"position\":{\"x\":0,\"y\":480},\"data\":{\"kind\":\"pool\",\"label\":\"🚀 launch 32\",\"initial\":5}},{\"id\":\"node_33\",\"type\":\"source\",\"position\":{\"x\":160,\"y\":480},\"data\":{\"kind\":\"source\",\"label\":\"Pool 33\",\"initial\":0}},{\"id\":\"node_34\",\"type\":\"pool\",\"position\":{\"x\":320,\"y\":480},\"data\":{\"kind\":\"pool\",\"label\":\"Ресурс 34\",\"initial\":5}},{\"id\":\"node_35\",\"type\":\"pool\",\"position\":{\"x\":480,\"y\":480},\"data\":{\"kind\":\"pool\",\"label\":\"مورد 35\",\"initial\":5}},{\"id\":\"node_36\",\"type\":\"source\",\"position\":{\"x\":640,\"y\":480},\"data\":{\"kind\":\"source\",\"label\":\"골드 36\",\"initial\":0}},{\"id\":\"node_37\",\"type\":\"pool\",\"position\":{\"x\":800,\"y\":480},\"data\":{\"kind\":\"pool\",\"label\":\"金庫 37\",\"initial\":5}},{\"id\":\"node_38\",\"type\":\"pool\",\"position\":{\"x\":960,\"y\":480},\"data\":{\"kind\":\"pool\",\"label\":\"🚀 launch 38\",\"initial\":5}},{\"id\":\"node_39\",\"type\":\"source\",\"position\":{\"x\":1120,\"y\":480},\"data\":{\"kind\":\"source\",\"label\":\"Pool 39\",\"initial\":0}}],\"edges\":[{\"id\":\"edge_0\",\"source\":\"node_0\",\"target\":\"node_1\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"edge_1\",\"source\":\"node_1\",\"target\":\"node_2\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2\"}},{\"id\":\"edge_2\",\"source\":\"node_2\",\"target\":\"node_3\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"3\"}},{\"id\":\"edge_3\",\"source\":\"node_3\",\"target\":\"node_4\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"4\"}},{\"id\":\"edge_4\",\"source\":\"node_4\",\"target\":\"node_5\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"edge_5\",\"source\":\"node_5\",\"target\":\"node_6\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2\"}},{\"id\":\"edge_6\",\"source\":\"node_6\",\"target\":\"node_7\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"3\"}},{\"id\":\"edge_7\",\"source\":\"node_7\",\"target\":\"node_8\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"4\"}},{\"id\":\"edge_8\",\"source\":\"node_8\",\"target\":\"node_9\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"edge_9\",\"source\":\"node_9\",\"target\":\"node_10\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2\"}},{\"id\":\"edge_10\",\"source\":\"node_10\",\"target\":\"node_11\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"3\"}},{\"id\":\"edge_11\",\"source\":\"node_11\",\"target\":\"node_12\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"4\"}},{\"id\":\"edge_12\",\"source\":\"node_12\",\"target\":\"node_13\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"edge_13\",\"source\":\"node_13\",\"target\":\"node_14\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2\"}},{\"id\":\"edge_14\",\"source\":\"node_14\",\"target\":\"node_15\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"3\"}},{\"id\":\"edge_15\",\"source\":\"node_15\",\"target\":\"node_16\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"4\"}},{\"id\":\"edge_16\",\"source\":\"node_16\",\"target\":\"node_17\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"edge_17\",\"source\":\"node_17\",\"target\":\"node_18\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2\"}},{\"id\":\"edge_18\",\"source\":\"node_18\",\"target\":\"node_19\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"3\"}},{\"id\":\"edge_19\",\"source\":\"node_19\",\"target\":\"node_20\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"4\"}},{\"id\":\"edge_20\",\"source\":\"node_20\",\"target\":\"node_21\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"edge_21\",\"source\":\"node_21\",\"target\":\"node_22\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2\"}},{\"id\":\"edge_22\",\"source\":\"node_22\",\"target\":\"node_23\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"3\"}},{\"id\":\"edge_23\",\"source\":\"node_23\",\"target\":\"node_24\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"4\"}},{\"id\":\"edge_24\",\"source\":\"node_24\",\"target\":\"node_25\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"edge_25\",\"source\":\"node_25\",\"target\":\"node_26\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2\"}},{\"id\":\"edge_26\",\"source\":\"node_26\",\"target\":\"node_27\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"3\"}},{\"id\":\"edge_27\",\"source\":\"node_27\",\"target\":\"node_28\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"4\"}},{\"id\":\"edge_28\",\"source\":\"node_28\",\"target\":\"node_29\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"edge_29\",\"source\":\"node_29\",\"target\":\"node_30\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2\"}},{\"id\":\"edge_30\",\"source\":\"node_30\",\"target\":\"node_31\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"3\"}},{\"id\":\"edge_31\",\"source\":\"node_31\",\"target\":\"node_32\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"4\"}},{\"id\":\"edge_32\",\"source\":\"node_32\",\"target\":\"node_33\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"edge_33\",\"source\":\"node_33\",\"target\":\"node_34\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2\"}},{\"id\":\"edge_34\",\"source\":\"node_34\",\"target\":\"node_35\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"3\"}},{\"id\":\"edge_35\",\"source\":\"node_35\",\"target\":\"node_36\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"4\"}},{\"id\":\"edge_36\",\"source\":\"node_36\",\"target\":\"node_37\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"edge_37\",\"source\":\"node_37\",\"target\":\"node_38\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2\"}},{\"id\":\"edge_38\",\"source\":\"node_38\",\"target\":\"node_39\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"3\"}}]}", + "payload": "eJyrVipOzkjNTVSyUsrJzy_QLS4pTcnM108vSizIUNJRKkstKs7Mz1OyMtRRystPSS1WsoquVspMASoHceMNgGpKKgtSgfzi_NKi5FQgvyC_OLMErKlaqULJykBHqRJI1uoopSSWAO2pVsrOzAMZANeQk5iUmgMUeLV5xevJcxRAZmbmAY1IBAoa1NbqIFtoiLCwID8_B9M6QzOcFkI1wKx72T7x6a7VCiAT4daZollnRMg6YyNirfswf1aDQk5iaV5yhgLIWJx2GhMOUxML4kM1AOgMBZCZOMPUhJAnzUyI9eSFBRe2Xmy82Hyx4WKjAshcnL40JWSphQGxlt5svdlxY-ON9QogM3FaaEY4WC1xpx1ciRVkKs6ANSeYWA2JTj7Q1AoyEqcXLQjZB7EMaClJqRVkLE47LQkHKyxLYrUXe3oFmYq7DEAqdfDnSmJ8ipJkwUbj9KshwdIHljGJsRiebMHG4rYUqQzCFcKwDEpcCEOTLthg3IGMVAzhz6DE-BVW1qKUQxg-JVgQwTIoqSkYbDJue5HKIpxpGJZTSUjEYHNxBzBSgYQvtxqZkJqGUcokDM8SLpSggUyMxYg0jLdgMkQqmXCFMCzPYrUWZxpGKZswghipcMKfX4nxKywNo5RNGA0GgmUTLK8SYyVKmwFv0WSEVDThCmFYhiUuhMFJGGwszvA1Itg8gmVYkhMx2GjcviVYOMHzK0mpGGwubluRyif8rV1jMxLSMNhY3GFMsKEEy69YLcWehsGG4vYnwZIJlleJsRIlDeMtmoyQiiZCLV_iQhiShlGKJozwJdhogmVYYjyLmobxNpyMCBZOsPxKjMWIJIy3eDImosMGy7MkJWKwwTgD2Zhwtw2WX0lIxWBTcXuVYOEEsRCYokhKw2BzcdtKRO8NlmGx2oyj_4a3A2dMsOEEy7DEeBYlDYONxu1bgoUTLL8SYzE8DYONxW0pET05WJ4lLohhaRhvZ86YYMMJll-J8SssCeNtNhkTLJtgeZXkRIy3aDImplMHy7CkpGL0bl2sjlJqSjryqBKICx5VgpqAPM6UWJSeWgJv2yGcCBq4AvLQnFCUCndEWk5-OVDEUAnhT7A9IDNQ7QGbimIPUilCrD1G6PaAzEC1B2wqij1I5Qax9hij2wMyA9UesKko9iAVFcTaY4JuD8gMVHvApqLYg1QykB0_IDNQ7QGbimIPUmFAdvyAzEC1B2wqij1IuZ_s-AGZgWoP2FQUe5CyPNnxAzID1R6wqSj2IGVxsuMHZAaqPWBTUfMpUouD7AgCG4KWU7EUCWSUCRhxBDYEzSrMUgF5UIbsaAIbgmYVZsGAPBBDfkmHWTRAzEW1iozCATOuMEsHiLmoVpFRPmDGFWYBATEX1SoyigjMuMIsIyDmolpFRimBGVeYxQTEXFSryCgoMOMKs6SAmItqFRllBWZcYRYWEHNRa1sySguMuAIbglbfYpYWyCMmZMcV2BA0q7C0IajSiMDSisAsLZBHRsiOK7AhaFZhlhbIwyHkxxVmaQExF9UqarQmwIagWYVZWiAPfpAfV5ilBcRcVKuo0aYAG4JmFWZpgTzKQX5cYZYWEHNRraJGywJsCJpVmKUF8mgG2XEFNgSt1YxZWiCPYJDfPscsLSDmolpFjbYF2BA0q7B0OqjRtgAbgmYVZmmBPEZBflxhlhYQc1GtokbbAmwImlWYpQXyWAT5cYVZWkDMRbWKGm0LsCFoVmGWFsjjDuTHFWZpATEX1Soy2xaxtQBHrAHr" + }, + { + "name": "long runs: match length 258 and distances 1 and 2", + "encoder": "fixed", + "text": "ababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "payload": "eJxLTBqFo3AUjsJROApH4SgchaNwFI7CUTgKR-EoHIWjcBSOwlE4CkchpbBiFIwCGgAAH5MG_w" + }, + { + "name": "literal-heavy text with few matches", + "encoder": "fixed", + "text": "{WO4bq6)dD!Bi$~43HcSMYD9za$CYVWytK\\\"3[+mxB(xBJY'X:'\\6{j!T+dpM%%WM+&8+,CN\"RgqbxiEdgi\"zR8w./]H!2cZS}M{:8Xsr}D#dUOb62~'.D3!cYT=C`d-?(_'2-LXLGac$|u_*>2/WM>y%J$/4h7[gm]2+<61jsl3eX+,i1gAK;_Pwt<<9]SEVAFM.Eb:V8Kr`UZ`}H/.GTjp1E5O$EX%\"-,_Q#3Mp&a0Ca\\4n(k!Ao,i3JMkcpAp_OB%=5x1LsRYUgOCQQAT[9#OC*d(gY/1&=k&ZGAK65%oe)Dky67x;K@?Uqu&4WOZ=1/mz_:if.L6%WG1F\"Y2^Bk5hj=W.tY_**s=CaZl^R1!`^g~=Z[0&9_C2k)3Zf>|.!a9#:]S!YU6$m^MB:ES!C=LLa+L(r)<7+44Gn3)pFKy{=nUKE3M\"^-k!O%B9@.**8A{9U6zeGAc'Rh\\c~RAYmN<;A6`5;4*7`xAZQ1!KP~0BFobN&@%b4Qc)uDmE!%Q.3/.B:]kC>2TsGDB6jEV`674Dn5(f!wL=Ryi~\\2QT{HM3CBW69~6CzEUmJgT8(CkK6`H00&?XZFX>8lq~Y=Dc1VIf\\[$A%YR<95T.:tMVnXx^zTbEjf+l0<~&NRmP^:tsk7{$X0.lz+=(QvkfB3;X#yVsPgLDv9CR9_V4=TB+D!q+!9l$s5h52k!lt]VQ9~0IxU3~$nc8m'c|]ynVS&/_LzN^_>w[J[\\\\h37+a*XL\"Y&5gPC4_DE2XK91yPr$D$BM]jV{kC#\\O[Q-:?3zVS;:R/3Z;blS5s){Bav#R\"SKlS#mJHzD4V[3K(^H/N=QA$*Wt%B'qk9j*vTYx1)A.aKn'>]85diTw]\"kBPD'FUFc$+63{*D-Io>N!@OeeDo'#>N$=XDWsR7;<5?t]$EWR@=Ty'%4daGf!w)CM/ZN?q]!s:i\"^&q,=Id/aw$d82/%`plziJcUw5=@og>xo33L#^c9C;cA~Ne0m{-(39Ohc95h}Q3L=\";,,$+=5=.KO1>OV?Z@Vj*`vX.;/AY$Q;8}?7eadH|L&bPR++2(f-#+#1W3ZJ^>,0\\(ni&a>e1<@fHM+J&H1wqa.t=7B1B]dZPp$'v*0s>3c;ia$0>OU`^C[YqZskn^.Q-0]$8,S!lPqL9b`>P:[:zBcQvb$qo|\\5L&X4|Ly482p[;&UN:=^>v^)DhDX`tc)](B!VJ\\/A8z89vZ*a)y[g1(cp*Z83W8kX^OGf5`fQH[El'`@8AJ9\"f.\\(V]]x8u&z$iZTK)a;,\\hfrfM[q%fI)vX,E/R_p}4L$X[r9[D%To+,9-VXS@uRpN&>rn=\\tqRk=%eS=6g}Xk%]WF:xQ]P", + "payload": "eJyrDvc3SSo000xxUXTKVKkzMfZIDvaNdLGsSlRxjgwLryzxjlEyjtbOrXDSqHDyilSPsFKPMavOUgzRTinwVVUN99VWs9DWcfZTCkovTKrIdE1Jz1SqCrIo19OP9VA0So4KrvWttrKIKC6qdVFOCfVPMjOqU9dzMVZMjgyxdU5I0bXXiFc30vWJ8HFPTFapKY3XsjPSD_e1q1T1UtE3yTCPTs-NNdK2MTPMKs4xTo3Q1sk0THf0to4PKC-xsbGMDXYNc3Tz1XNNsgqz8C5KCI1KqPXQ13MPySowdDX1V3GNUFXS1YkPVDb2LVBLNHBOjDHJ08hWdMzXyTT28s1OLnAsiPd3UrU1rTD0KQ6KDE33dw4MdAyJtlT2d9ZK0UiP1DdUs81Wi3J39DYzVc1P1XTJrjQzr7D2drAPLSxVMwn3j7I11M-tirfKTNPzMVMNdzd0U4o0inPKNs3IsskIUXZXTDVM0tfR8LEwsLMN1yuJjNfSKrZ1TozKiQsyVEyIS6-zjYo2ULOMdzbK1jSOSrOr0VNMtFS2ig1WjAw1U8mN83Wycg1WdLb18UnU9tEo0rQx1zYxcc8z1ixw866sts0L9XY19lWK081W9Fd1snTQ09KycKy2DDWrSnV3TFYPyohJrgtyjMz1s7F2NEswtTbRMk-ocIwKNFT0DqgzcHLLT_JTc1BNMglM1ix1yXVVVA3UM9bXc7KKzXa2MwopdndxMsuyyQnQ0U0JUKqpTLDKcXbJM0hL8DQILNWPMHTMLVQPCdeOcUt3Lcp38I9Q9XOIDwrJUjWM9jE2NFeKSIixVs1I8ElONlEurIxSclQxMTAoV_NO8nHxDvbI0FMLza6wtDbILc1RNK8zCYzVU_YLTDDWTPW2jkjUD3a20Ix2MYmPMwpX19d0y8zzN07KcvOvyVIPcvAvTDPNDQqo1cvztzbSqfYPi8kJrE5wdAjXsojOKfYKqvOyLArQNPFOKw3LSwgu0DeJifeLSnT1jrRNrrBN1CkNsnMNSzAzN3HJM9VIUyz3sQ2qzKyLMQoMqfbwNXZ2CjezrDNzrnINzfVKD7HQcM72NkvwMDBQs4-Icouws8gprIu0dUk2DPNMi4lWcVSNDLKxNA3RsyrxDcuLqIirCklyzUrTzjGwqVPzC8oNiLMqKc42r1aJMNDLqdK21Qgsy05zMraOUK4MKw5I93Eps3QOsowPM7ENcdJ2USzUVrTMUSk2zTA1ylbMKYkNC7SsM_CsCDWuU8lLtshVT66JrcwLC1bTj_ep8ouLtyuP9oqOickwNtdO1IrwUYpUM00PcDaJd3E1ivC2NKwMKFJxUXHyjc0Kq852Vo7xjw7UtbI3rgoLtrYK0jeOsk7KCTYt1qx2SixTDlIK9s4JVs718qhyMQmLNvbWiPPQ97MNdFTRCi9RdVIvzLbM0ioLiaww1HTUS_TOU7eLtTBNyQwpj1XKdgpwUXcLdUtW0TYzrtZy0fXMt_NTdPBPTXXJV1e281OxjXAJLw4yt7YxtS-JVXEND3KwDalUVzVJSXQHhr-ms69-lJ99YaxisVWmUpxaoY6tZ4p-YrlKioWRvmpCQU5VpldyaLmprUN-ul1FvrGxj3JcsqWzdbJjnV-qQW61roaxpX9GsqVpRm2gsY-tkrWNq72ahrpuRWC-anh8lW56gJJ_rFFYRblhimeCarS1b7p1SFWwdma5sZmau3KUh5J5RFCFqreGa2WioSKwSFBz9bU186k1cc71cnSPVrFMUbYqVs-ySAiOdQ411VQzT46scgpxUVaNUVQ0ca4zMjJKMcvwKI9WNnZMd4n19jUxzLU1z84xKnd2siyssPfXdPArzStLLi2sUdOtUVGJslUqjNBMLKlOVYkPq441jg22L9UyTjA08desK8oxS_ez09FR0bY1tdXz9je08w-zj3IIy9JKKIvQs9Z3jFQJtLaotTdPTUzxqPFRSwoI0tY20kjTVdZWNgw3jvKKs9MxiNHIy1RLtEs1tHFI8_DV9lLzMCwvTNQrsTV3MnSKTYkKKFBRL9MyKLYzTrbOTFQxsPMPTYhzjo4sjCrOzovTC9Q1iFWx0AlWzAko9LFMSrALsIq2qnJKDixLUinMr4kx9VGLMKnxqTSxMCqItlYL9bOyjbMri9N0yXCJSChJ1ozVcFIM84rRd7SosrAsi9JK1KyMTjfUSC7QirIwDrfIjojzd08zTUgL9Ih2zVFPcLBw9LJUsgl2i6-Oz8zJUTVytfAyDDK2DTKJd0iyydHzcc5RcndPjYnxDzJWDAo2L9QtUKwrUYkqUTMPLQ8oytNVz1TK8E6OVKkNjdILVc1QVYu3qVCMUk9Jdiz0zXOJzbaxj7HPVldL8Dc3TbMpyfGs8dF0cXEN8aqzCLXUV_XRN_N39AsIT_N0zw_Ttk5yCNU2VlMKdghT0tbUd3W1drfUDPWtjlRSSQ6t08rxrlJ19Ip2M3KIACZT94IYNf0ER_NQL-uAFB_9UAtVpRSdKs9447pQncoYbWOtQE0DJU9Lew1d23TtJMtipYQ65-AAuzS9GI2w2NgKi1K1KpXMqBBvzURrnZiMtKI03-hC1TRPzbIIHVf9oPiCWhMflYjoIstoF9WQfG0dS92wiGCH0qACPzW7ojzbmJLCoGxb1dRgW7P02ohs1dhwN6uKwNgAAHOyUDc" + }, + { + "name": "one byte", + "encoder": "fixed", + "text": "a", + "payload": "eJxLBAAAYgBi" + }, + { + "name": "empty", + "encoder": "fixed", + "text": "", + "payload": "eJwDAAAAAAE" + }, + { + "name": "stored blocks (the first, uncompressed fallback)", + "encoder": "stored", + "text": "{\"schema\":\"loop-studio/graph\",\"version\":1,\"nodes\":[{\"id\":\"node_0\",\"type\":\"source\",\"position\":{\"x\":0,\"y\":0},\"data\":{\"kind\":\"source\",\"label\":\"골드 0\",\"initial\":0}},{\"id\":\"node_1\",\"type\":\"pool\",\"position\":{\"x\":160,\"y\":0},\"data\":{\"kind\":\"pool\",\"label\":\"金庫 1\",\"initial\":5}},{\"id\":\"node_2\",\"type\":\"pool\",\"position\":{\"x\":320,\"y\":0},\"data\":{\"kind\":\"pool\",\"label\":\"🚀 launch 2\",\"initial\":5}},{\"id\":\"node_3\",\"type\":\"source\",\"position\":{\"x\":480,\"y\":0},\"data\":{\"kind\":\"source\",\"label\":\"Pool 3\",\"initial\":0}},{\"id\":\"node_4\",\"type\":\"pool\",\"position\":{\"x\":640,\"y\":0},\"data\":{\"kind\":\"pool\",\"label\":\"Ресурс 4\",\"initial\":5}},{\"id\":\"node_5\",\"type\":\"pool\",\"position\":{\"x\":800,\"y\":0},\"data\":{\"kind\":\"pool\",\"label\":\"مورد 5\",\"initial\":5}}],\"edges\":[{\"id\":\"edge_0\",\"source\":\"node_0\",\"target\":\"node_1\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"edge_1\",\"source\":\"node_1\",\"target\":\"node_2\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2\"}},{\"id\":\"edge_2\",\"source\":\"node_2\",\"target\":\"node_3\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"3\"}},{\"id\":\"edge_3\",\"source\":\"node_3\",\"target\":\"node_4\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"4\"}},{\"id\":\"edge_4\",\"source\":\"node_4\",\"target\":\"node_5\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}}]}", + "payload": "eAEB8QQO-3sic2NoZW1hIjoibG9vcC1zdHVkaW8vZ3JhcGgiLCJ2ZXJzaW9uIjoxLCJub2RlcyI6W3siaWQiOiJub2RlXzAiLCJ0eXBlIjoic291cmNlIiwicG9zaXRpb24iOnsieCI6MCwieSI6MH0sImRhdGEiOnsia2luZCI6InNvdXJjZSIsImxhYmVsIjoi6rOo65OcIDAiLCJpbml0aWFsIjowfX0seyJpZCI6Im5vZGVfMSIsInR5cGUiOiJwb29sIiwicG9zaXRpb24iOnsieCI6MTYwLCJ5IjowfSwiZGF0YSI6eyJraW5kIjoicG9vbCIsImxhYmVsIjoi6YeR5bqrIDEiLCJpbml0aWFsIjo1fX0seyJpZCI6Im5vZGVfMiIsInR5cGUiOiJwb29sIiwicG9zaXRpb24iOnsieCI6MzIwLCJ5IjowfSwiZGF0YSI6eyJraW5kIjoicG9vbCIsImxhYmVsIjoi8J-agCBsYXVuY2ggMiIsImluaXRpYWwiOjV9fSx7ImlkIjoibm9kZV8zIiwidHlwZSI6InNvdXJjZSIsInBvc2l0aW9uIjp7IngiOjQ4MCwieSI6MH0sImRhdGEiOnsia2luZCI6InNvdXJjZSIsImxhYmVsIjoiUG9vbCAzIiwiaW5pdGlhbCI6MH19LHsiaWQiOiJub2RlXzQiLCJ0eXBlIjoicG9vbCIsInBvc2l0aW9uIjp7IngiOjY0MCwieSI6MH0sImRhdGEiOnsia2luZCI6InBvb2wiLCJsYWJlbCI6ItCg0LXRgdGD0YDRgSA0IiwiaW5pdGlhbCI6NX19LHsiaWQiOiJub2RlXzUiLCJ0eXBlIjoicG9vbCIsInBvc2l0aW9uIjp7IngiOjgwMCwieSI6MH0sImRhdGEiOnsia2luZCI6InBvb2wiLCJsYWJlbCI6ItmF2YjYsdivIDUiLCJpbml0aWFsIjo1fX1dLCJlZGdlcyI6W3siaWQiOiJlZGdlXzAiLCJzb3VyY2UiOiJub2RlXzAiLCJ0YXJnZXQiOiJub2RlXzEiLCJ0eXBlIjoibG9vcCIsImRhdGEiOnsia2luZCI6InJlc291cmNlIiwiZmxvdyI6IjEifX0seyJpZCI6ImVkZ2VfMSIsInNvdXJjZSI6Im5vZGVfMSIsInRhcmdldCI6Im5vZGVfMiIsInR5cGUiOiJsb29wIiwiZGF0YSI6eyJraW5kIjoicmVzb3VyY2UiLCJmbG93IjoiMiJ9fSx7ImlkIjoiZWRnZV8yIiwic291cmNlIjoibm9kZV8yIiwidGFyZ2V0Ijoibm9kZV8zIiwidHlwZSI6Imxvb3AiLCJkYXRhIjp7ImtpbmQiOiJyZXNvdXJjZSIsImZsb3ciOiIzIn19LHsiaWQiOiJlZGdlXzMiLCJzb3VyY2UiOiJub2RlXzMiLCJ0YXJnZXQiOiJub2RlXzQiLCJ0eXBlIjoibG9vcCIsImRhdGEiOnsia2luZCI6InJlc291cmNlIiwiZmxvdyI6IjQifX0seyJpZCI6ImVkZ2VfNCIsInNvdXJjZSI6Im5vZGVfNCIsInRhcmdldCI6Im5vZGVfNSIsInR5cGUiOiJsb29wIiwiZGF0YSI6eyJraW5kIjoicmVzb3VyY2UiLCJmbG93IjoiMSJ9fV1933eW1Q" + }, + { + "name": "risky-factory diagram, 18 nodes", + "encoder": "fixed", + "text": "{\"schema\":\"loop-studio/graph\",\"version\":1,\"nodes\":[{\"id\":\"ore_source\",\"type\":\"source\",\"position\":{\"x\":0,\"y\":20},\"data\":{\"kind\":\"source\",\"label\":\"Ore Source\",\"activation\":\"automatic\",\"mode\":\"pushAny\"}},{\"id\":\"ore_stock\",\"type\":\"pool\",\"position\":{\"x\":210,\"y\":20},\"data\":{\"kind\":\"pool\",\"label\":\"Ore Stock\",\"activation\":\"passive\",\"initial\":8,\"capacity\":50,\"mode\":\"pullAny\"}},{\"id\":\"ore_router\",\"type\":\"gate\",\"position\":{\"x\":420,\"y\":20},\"data\":{\"kind\":\"gate\",\"label\":\"Ore Router\",\"activation\":\"automatic\",\"distribution\":\"deterministic\"}},{\"id\":\"refined_ore\",\"type\":\"pool\",\"position\":{\"x\":640,\"y\":20},\"data\":{\"kind\":\"pool\",\"label\":\"Refined Ore\",\"activation\":\"passive\",\"initial\":0,\"capacity\":12,\"mode\":\"pullAny\"}},{\"id\":\"tailings\",\"type\":\"pool\",\"position\":{\"x\":640,\"y\":180},\"data\":{\"kind\":\"pool\",\"label\":\"Tailings\",\"activation\":\"passive\",\"initial\":0,\"capacity\":15,\"mode\":\"pullAny\"}},{\"id\":\"waste_drain\",\"type\":\"drain\",\"position\":{\"x\":420,\"y\":180},\"data\":{\"kind\":\"drain\",\"label\":\"Waste Drain\",\"activation\":\"automatic\",\"mode\":\"pullAny\"}},{\"id\":\"energy_source\",\"type\":\"source\",\"position\":{\"x\":0,\"y\":340},\"data\":{\"kind\":\"source\",\"label\":\"Energy Source\",\"activation\":\"automatic\",\"mode\":\"pushAny\"}},{\"id\":\"energy_pool\",\"type\":\"pool\",\"position\":{\"x\":210,\"y\":340},\"data\":{\"kind\":\"pool\",\"label\":\"Energy Pool\",\"activation\":\"passive\",\"initial\":6,\"capacity\":20,\"mode\":\"pullAny\"}},{\"id\":\"assembly\",\"type\":\"converter\",\"position\":{\"x\":640,\"y\":340},\"data\":{\"kind\":\"converter\",\"label\":\"Assembly Converter\",\"activation\":\"automatic\",\"mode\":\"pullAny\"}},{\"id\":\"components\",\"type\":\"pool\",\"position\":{\"x\":860,\"y\":340},\"data\":{\"kind\":\"pool\",\"label\":\"Components\",\"activation\":\"passive\",\"initial\":0,\"capacity\":30,\"mode\":\"pullAny\"}},{\"id\":\"quality_gate\",\"type\":\"gate\",\"position\":{\"x\":1080,\"y\":340},\"data\":{\"kind\":\"gate\",\"label\":\"Quality Gate\",\"activation\":\"automatic\",\"distribution\":\"probabilistic\"}},{\"id\":\"sales_drain\",\"type\":\"drain\",\"position\":{\"x\":1300,\"y\":60},\"data\":{\"kind\":\"drain\",\"label\":\"Sales Drain\",\"activation\":\"automatic\",\"mode\":\"pullAny\"}},{\"id\":\"finished\",\"type\":\"pool\",\"position\":{\"x\":1300,\"y\":220},\"data\":{\"kind\":\"pool\",\"label\":\"Finished Goods\",\"activation\":\"passive\",\"initial\":0,\"capacity\":25,\"mode\":\"pullAny\"}},{\"id\":\"scrap\",\"type\":\"pool\",\"position\":{\"x\":1300,\"y\":460},\"data\":{\"kind\":\"pool\",\"label\":\"Scrap Pool\",\"activation\":\"passive\",\"initial\":0,\"capacity\":12,\"mode\":\"pullAny\"}},{\"id\":\"recycler\",\"type\":\"converter\",\"position\":{\"x\":1300,\"y\":620},\"data\":{\"kind\":\"converter\",\"label\":\"Recycler\",\"activation\":\"automatic\",\"mode\":\"pullAny\"}},{\"id\":\"salvage\",\"type\":\"pool\",\"position\":{\"x\":1300,\"y\":780},\"data\":{\"kind\":\"pool\",\"label\":\"Salvage Pool\",\"activation\":\"passive\",\"initial\":0,\"capacity\":10,\"mode\":\"pullAny\"}},{\"id\":\"salvage_drain\",\"type\":\"drain\",\"position\":{\"x\":1080,\"y\":780},\"data\":{\"kind\":\"drain\",\"label\":\"Salvage Drain\",\"activation\":\"automatic\",\"mode\":\"pullAny\"}},{\"id\":\"end_defect\",\"type\":\"end\",\"position\":{\"x\":1080,\"y\":560},\"data\":{\"kind\":\"end\",\"label\":\"Critical Defect\",\"activation\":\"automatic\"}}],\"edges\":[{\"id\":\"e_ore_in\",\"source\":\"ore_source\",\"target\":\"ore_stock\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2D6\"}},{\"id\":\"e_ore_route\",\"source\":\"ore_stock\",\"target\":\"ore_router\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"6\"}},{\"id\":\"e_route_refine\",\"source\":\"ore_router\",\"target\":\"refined_ore\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"4\"}},{\"id\":\"e_route_tail\",\"source\":\"ore_router\",\"target\":\"tailings\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"e_tail_waste\",\"source\":\"tailings\",\"target\":\"waste_drain\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1-3\"}},{\"id\":\"e_energy_in\",\"source\":\"energy_source\",\"target\":\"energy_pool\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1-3\"}},{\"id\":\"e_refine_asm\",\"source\":\"refined_ore\",\"target\":\"assembly\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"3\"}},{\"id\":\"e_energy_asm\",\"source\":\"energy_pool\",\"target\":\"assembly\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"e_asm_comp\",\"source\":\"assembly\",\"target\":\"components\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2\"}},{\"id\":\"e_comp_qc\",\"source\":\"components\",\"target\":\"quality_gate\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"4\"}},{\"id\":\"e_qc_finished\",\"source\":\"quality_gate\",\"target\":\"finished\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"17\"}},{\"id\":\"e_qc_scrap\",\"source\":\"quality_gate\",\"target\":\"scrap\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"3\"}},{\"id\":\"e_qc_end\",\"source\":\"quality_gate\",\"target\":\"end_defect\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"e_fin_sales\",\"source\":\"finished\",\"target\":\"sales_drain\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1-3\"}},{\"id\":\"e_scrap_rec\",\"source\":\"scrap\",\"target\":\"recycler\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"2\"}},{\"id\":\"e_rec_salvage\",\"source\":\"recycler\",\"target\":\"salvage\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}},{\"id\":\"e_salvage_out\",\"source\":\"salvage\",\"target\":\"salvage_drain\",\"sourceHandle\":\"out\",\"targetHandle\":\"in\",\"type\":\"loop\",\"data\":{\"kind\":\"resource\",\"flow\":\"1\"}}],\"recommendedRunConfig\":{\"baseSeed\":1,\"runs\":500,\"steps\":40,\"tracked\":[\"ore_stock\",\"energy_pool\",\"components\",\"finished\",\"scrap\",\"salvage\"]}}", + "payload": "eJyrVipOzkjNTVSyUsrJzy_QLS4pTcnM108vSizIUNJRKkstKs7Mz1OyMtRRystPSS1WsoquVspMASrPL0qNL84vLUpOBaorqSxIBYrB-QX5xZklYI3VShVKVgY6SpVKVkYGtTpKKYklQMuqlbIz80CmwHXkJCal5gAF_ItSFYJhgonJJZlliRCDlBJLS_JzgZxkoEQu0C1AoYLS4gzHvEql2lodZFeV5CdnIxxVkJ-fg-kkI0PcjoLqQHES1EwUFxUkFhdnloEcmpkHNDsRqNhCRyk5sSAxObMEaLYp0Aq4S3NyMF1alF9aklqEcGp6YgmW0DMxwu1UqA5kpwbBDMUZeimZxSVFmUmlUKmUVKD6XKAXikHSCBcWpaZl5qWmxANdSig0zUyIDs0giKkKQKcSEZ5AYxHhaWiEJzxLEjNzMvPSi4l1qqEFYbeGIMwkzaGmeBxanlhckhqfUpSYmYdwK4yLI-qxOhamB-bacJDBCi5QUcJ5B91hqXmpRemVJGZqYxNicrUr2GhKMjbUcdAIIiprY3UaWgxDHRYAESUUyWbIkQyKGpxhCdSbmpuUU4lwa3J-HrA0heRMHEkSq4OR9cFc7Qg1XcEZSZL0CE_Ozy3Iz0vNKyGYaSzMiA9SZ2RTSco2xvhCtLA0MQeoKh5a4uEtMQ0NLPC4F63MDIQYrOAOESa21Cwoyk9KTAKWD2ilZnFiTmoxkdnb0NgA4k4zIrJ3MMhgCrI3sNjNLM5ITSEU13BHGRFRmrtBDVVwz89PITnCjfCVk8XJwEYI0Y41wRaEaI4NBplIbF4nvuYpSk2uTM5Brsfx5HVElGMLXWyZPQhhPOmRDkyNZYnpBOtvuKvMiagVgQkRZCZ5AYkvi0MdS2zmgWVyrG7GknvAjqakekyJT0lNS00uQTgNKIbHYabY0iREC7ywLALqTE7MUXCBmYzDYbW1sTpKqSnpyE3wVFDTLB7sHWiNi94sTyxKTy1BaxZDZD0S81JywBpKwf4Bq4QLIgc_qF8AKv5QvVGUCrclLSe_HChi5GKGHFpgt4EbuBjOg7XPkV0HbwrTynmojgNbFw9p4aK7D9EqhzkQtSVMKxeaYHEhqFVL2H1IbV9aOc4QxXEgC-PBDVlkxyE3wWFOQ23t0sx1usYo7oO2FlGzBkb7FuZG1LYlvdwISVTxicW5yI5E63TBnIjUnqSV-7CGIJrr0FrhdHQdavoDOise1HpFdhtykxvmMJQWLs0KPhSngayMLwTVJHCXobazYW5Da9DSp1QpTI5HagfCXYjeuIa5EUMtDSLWHN2BsIYfQdehKqR1jgC6DFJ3E3QXSkuBPhkCGFHx4J4HsvuQW_zwMEPpntCrrAPHFLDEQ8kW8AY-opqFt3Tpk1eBFoJCDdpCRiqDEQ16pHBDUUbrCIW1hSHGI8IM0Z5Hcxnt4xTSBgUGTn5uLjCJp6YEleYBByDSMtNB-pISi1ODU4GJDTRWXFSaB2ynmoI6FcDqvwDIBg1xlBQlJmeDVESjtANRKxWUwhK5_IFldmgIxNbWAgCo83X9" + } +] diff --git a/src/releaseNotes/releaseNotes.ts b/src/releaseNotes/releaseNotes.ts index 4be8c2e0..cc3e3a57 100644 --- a/src/releaseNotes/releaseNotes.ts +++ b/src/releaseNotes/releaseNotes.ts @@ -38,6 +38,13 @@ export const releaseNoteIdFor = (version: string): ReleaseNoteId => `release:${v * mechanics, no test counts, no pull-request numbers. */ export const RELEASE_NOTES: readonly ReleaseNote[] = [ + { + // issue #301 decision 1 - share links use only the browser's own compression + id: 'release:0.17.1', + version: '0.17.1', + date: '2026-10-04', + items: ['whatsNew.v0171.compression', 'whatsNew.v0171.unavailable', 'whatsNew.v0171.compatible'], + }, { // the date is the day it is deployed, set right before the merge (issue #300) id: 'release:0.17.0', diff --git a/src/store/protectedLink.test.ts b/src/store/protectedLink.test.ts index c3b9e6e8..d23eabc2 100644 --- a/src/store/protectedLink.test.ts +++ b/src/store/protectedLink.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { serialize } from '../model/serialize' import type { LoopEdge, LoopNode } from '../model/types' +import { encodeShareText } from '../model/share' import { sealShareText } from '../model/shareProtected' import { useGraphStore } from './graphStore' import { useMcStore } from './mcStore' @@ -294,3 +295,52 @@ describe('the plain path is unchanged by the protected one', () => { expect(phase()).toEqual({ phase: 'idle' }) }) }) + +// issue #301 decision 1 - there is no bundled zlib fallback any more, so a page +// without Compression Streams opens no link of either kind (SEMANTICS-U.md SS U1.3) +describe('a page without Compression Streams', () => { + it('a plain link ⇒ the "Share link" notice, the fragment KEPT, the graph untouched', async () => { + const hash = `#g1=${(await encodeShareText(docString())).payload}` + vi.stubGlobal('DecompressionStream', undefined) + const before = { ids: nodeIds(), rev: rev() } + const { strip, result, done } = start(hash) + await tick() + expect(phase()).toEqual({ phase: 'notice', notice: 'plain-no-compression' }) + expect(result.outcome).toBeNull() // the boot flow waits for the notice + dismissProtectedNotice() + expect(await done).toEqual({ kind: 'failed', reason: 'share-unavailable' }) + expect(strip).not.toHaveBeenCalled() + expect({ ids: nodeIds(), rev: rev() }).toEqual(before) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith('Loop Studio: this browser cannot open share links; a current browser is required.') + }) + + it('a protected link ⇒ the same sentence, checked before Web Crypto, the fragment KEPT, no prompt', async () => { + const hash = await protectedHash() + vi.stubGlobal('DecompressionStream', undefined) + vi.stubGlobal('crypto', {}) // both missing: the message names Compression Streams + const { strip, done } = start(hash) + await tick() + expect(phase()).toEqual({ phase: 'notice', notice: 'no-compression' }) + dismissProtectedNotice() + expect(await done).toEqual({ kind: 'failed', reason: 'share-unavailable' }) + expect(strip).not.toHaveBeenCalled() + }) + + it('a broken protected link is still damaged (the structure check needs neither)', async () => { + vi.stubGlobal('DecompressionStream', undefined) + const { strip, done } = start('#p1=AAAA') + await tick() + expect(phase()).toEqual({ phase: 'notice', notice: 'damaged' }) + dismissProtectedNotice() + await done + expect(strip).toHaveBeenCalledTimes(1) + }) + + it('a link this build does not know is handled as before', async () => { + vi.stubGlobal('DecompressionStream', undefined) + const strip = vi.fn() + expect(await consumeShareLink({ hash: '#g2=abc', confirm: NEVER, stripFragment: strip })).toEqual({ kind: 'failed', reason: 'unsupported-version' }) + expect(strip).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/store/protectedLink.ts b/src/store/protectedLink.ts index 3fb46b2f..7247ac69 100644 --- a/src/store/protectedLink.ts +++ b/src/store/protectedLink.ts @@ -24,7 +24,7 @@ import { create } from 'zustand' import { deserialize } from '../model/serialize' -import type { FragmentKind } from '../model/share' +import { type FragmentKind, shareCompressionAvailable } from '../model/share' import { ProtectedShareError, openProtectedBytes, protectedShareAvailable, readProtectedPayload } from '../model/shareProtected' import { applySharedGraph, replaceConfirmed } from './shareApply' @@ -33,6 +33,12 @@ export type ProtectedNotice = | 'unavailable' // no Web Crypto here; the fragment is left in place | 'newer' // a protected link from a newer version; the fragment is left in place | 'content' // the password was right and what was sealed is not a diagram + // issue #301 decision 1 - no Compression Streams here, so NO share link opens + // (there is no bundled fallback, SEMANTICS-U.md SS U1.3). The fragment is left + // in place so the address can be opened in another browser. Two names only + // for the dialog title: one for a protected link, one for a plain `g1` link. + | 'no-compression' + | 'plain-no-compression' export type ProtectedOpenState = | { phase: 'idle' } @@ -43,7 +49,7 @@ export type ProtectedOpenState = export type ProtectedOutcome = | { kind: 'loaded' } | { kind: 'cancelled' } - | { kind: 'failed'; reason: 'protected-damaged' | 'protected-unavailable' | 'protected-newer' | 'protected-content' } + | { kind: 'failed'; reason: 'protected-damaged' | 'protected-unavailable' | 'protected-newer' | 'protected-content' | 'share-unavailable' } type ProtectedFragment = Extract @@ -55,6 +61,8 @@ const LINE = { newer: 'Loop Studio: this protected share link needs a newer version of Loop Studio.', auth: 'Loop Studio: a protected share link was not opened; the password is wrong or the link is damaged.', content: 'Loop Studio: a protected share link opened, but what it carries is not a diagram.', + 'no-compression': 'Loop Studio: this browser cannot open share links; a current browser is required.', + 'plain-no-compression': 'Loop Studio: this browser cannot open share links; a current browser is required.', } as const const NOTICE_REASON = { @@ -62,6 +70,8 @@ const NOTICE_REASON = { unavailable: 'protected-unavailable', newer: 'protected-newer', content: 'protected-content', + 'no-compression': 'share-unavailable', + 'plain-no-compression': 'share-unavailable', } as const /** the sealed bytes of the link being opened - memory only */ @@ -128,6 +138,9 @@ export function openProtectedLink( opts.strip() return showNotice('damaged') } + // no Compression Streams: no link of any kind opens here - checked before + // Web Crypto, so the message names the real limit + if (!shareCompressionAvailable()) return showNotice('no-compression') if (!protectedShareAvailable()) return showNotice('unavailable') sealed = bytes @@ -136,6 +149,22 @@ export function openProtectedLink( }) } +/** + * Issue #301 decision 1 - a plain `g1` link on a page without Compression + * Streams. Nothing is decoded and the fragment is NOT removed, so the same + * address can be opened in another browser; the notice says why. Resolves when + * the notice is dismissed, like `openProtectedLink`, so what must come after + * the boot flow (the first-run Welcome card) still waits for it. + */ +export function openUnavailableShareLink(): Promise { + return new Promise((resolve) => { + if (settle) end({ kind: 'cancelled' }) + settle = resolve + confirmReplace = undefined + showNotice('plain-no-compression') + }) +} + /** * One password attempt. Ignored unless the prompt is open and idle. A wrong * password leaves the prompt open; there is no limit and no lock-out. diff --git a/src/store/shareLink.ts b/src/store/shareLink.ts index 88ea439b..630e5382 100644 --- a/src/store/shareLink.ts +++ b/src/store/shareLink.ts @@ -19,8 +19,9 @@ import { ShareError, classifyFragment, decodeShareText, + shareCompressionAvailable, } from '../model/share' -import { type ProtectedOutcome, openProtectedLink } from './protectedLink' +import { type ProtectedOutcome, openProtectedLink, openUnavailableShareLink } from './protectedLink' import { applySharedGraph, replaceConfirmed, replacePrompt } from './shareApply' export { replacePrompt } @@ -82,6 +83,11 @@ export async function consumeShareLink(opts: Options = {}): Promise { + if (!shareCompressionAvailable()) return { status: 'no-compression' } const { payload, bytes } = await encodeShareText(doc) const cap = shareCap() if (bytes > cap) return { status: 'too-large', bytes, cap } @@ -70,6 +73,8 @@ export type ProtectedShareLinkResult = * No side effects; the password is passed straight through and not kept. */ export async function prepareProtectedShareLink(doc: string, password: string): Promise { + // before the key derivation: a page that cannot compress cannot make any link + if (!shareCompressionAvailable()) return { status: 'no-compression' } let sealed: { payload: string; bytes: number } try { sealed = await sealShareText(doc, password)