From 578fcd9d4ff0d11df152fb5d52c0cfebe2530880 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:43:35 +0900 Subject: [PATCH 1/8] feat(share): the sealed transport for protected links, checked against an independent implementation Part of #300. The first of five staged commits; nothing is reachable from the UI yet. `src/model/shareProtected.ts` is loop-share-protected/1: a `p1` payload is base64url of salt (16), IV (12), ciphertext and tag (16), with no header byte. The plaintext is the zlib-deflated Graph JSON, the bytes a plain `g1` link encodes. The key is PBKDF2-HMAC-SHA-256 over the NFC-normalised password at 600,000 iterations into AES-256-GCM, never extractable, with `encrypt` usage when sealing and `decrypt` when opening. The additional authenticated data is the UTF-8 format identifier, so a link re-labelled as another version does not open. Web Crypto only. The structure check (60 to 8,192 characters, strict base64url) runs before any key is derived. Every failure of the derivation or the decryption leaves the module as one typed error with one reason: the browser reports data shorter than the tag with a different message, and that message must never reach a screen or a log. Content that authenticates and is not a bounded zlib stream is a separate reason. The module has no console call and keeps no password. The unit tests carry a second implementation written with node:crypto and node:zlib and their own base64url. Each side opens what the other sealed, a fixed known-answer vector opens, and mutations of the independent side (another identifier as AAD, no AAD, 599,999 iterations) do not. Product code therefore has no way to be handed a salt or an IV. The two Node modules are declared by hand in `src/test/nodeReference.d.ts` so the app's type program does not take on Node's globals. `npm run check:share-crypto` keeps key handling in that one module: the key and cipher operations, `subtle` and `getRandomValues` are named only there (plus the existing digest and id generation), nothing imports a `node:` module, the iteration count and the identifier are written once, `extractable` is the literal false, and package.json names no cryptography library. Seventeen mutations of a scratch copy each fail it. Alone, this commit fails `check:change-declaration`: the declaration and the 0.17.0 release note land with the last commits of the pull request. --- .github/workflows/ci.yml | 2 + package.json | 1 + scripts/check-share-crypto.mjs | 167 ++++++++++++++++ src/model/shareProtected.test.ts | 319 +++++++++++++++++++++++++++++++ src/model/shareProtected.ts | 206 ++++++++++++++++++++ src/test/nodeReference.d.ts | 33 ++++ 6 files changed, 728 insertions(+) create mode 100644 scripts/check-share-crypto.mjs create mode 100644 src/model/shareProtected.test.ts create mode 100644 src/model/shareProtected.ts create mode 100644 src/test/nodeReference.d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1023da2..ba269de4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,8 @@ jobs: run: npm run check:control-bytes - name: Storage port — browser storage is reached through one module only run: npm run check:storage-port + - name: Share crypto — key handling and encryption stay in one module, with fixed parameters + run: npm run check:share-crypto - name: Change declaration — a change that ships says whether it is user-facing, with its version and release note run: npm run check:change-declaration env: diff --git a/package.json b/package.json index f1a305ba..abe64c76 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "check:locale-lists": "node scripts/check-locale-lists.mjs", "check:snapshot-policy": "node scripts/check-snapshot-policy.mjs", "check:storage-port": "node scripts/check-storage-port.mjs", + "check:share-crypto": "node scripts/check-share-crypto.mjs", "check:change-declaration": "node scripts/check-change-declaration.mjs", "check:e2e-shards": "node scripts/e2e-shards.mjs check", "check:functional-glyphs": "node scripts/check-functional-glyphs.mjs", diff --git a/scripts/check-share-crypto.mjs b/scripts/check-share-crypto.mjs new file mode 100644 index 00000000..9a100e08 --- /dev/null +++ b/scripts/check-share-crypto.mjs @@ -0,0 +1,167 @@ +// Issue #300 — key handling and encryption have ONE module. +// +// A protected share link (`loop-share-protected/1`, docs/specs/SEMANTICS-P.md) +// is sealed and opened in `src/model/shareProtected.ts` and nowhere else. The +// format's promises - the iteration count is fixed, the key is never +// extractable, the browser's own error text never reaches a screen or a log - +// hold only while every such call sits in that one file, where the unit tests +// pin them from the outside. A second call site would be outside those tests. +// +// node scripts/check-share-crypto.mjs +// +// THE RULE IS LEXICAL, like `check:storage-port`. In product code: +// +// - the key and cipher operations of Web Crypto +// deriveKey · deriveBits · importKey · exportKey · generateKey · +// wrapKey · unwrapKey · encrypt · decrypt +// appear as a property name (or as a string that is exactly the name) only +// in the module; +// - `subtle` appears only in the module and in `src/model/workspace.ts`, +// which hashes with `digest` and may do nothing else with it; +// - `getRandomValues` appears only in the module and in +// `src/model/revision.ts` (revision ids); +// - nothing imports a `node:` module or a module named `crypto`. +// +// Inside the module: +// - `console` and `exportKey` do not appear at all; +// - the iteration count `600000` and the format identifier +// `loop-share-protected/1` are each written exactly once, and neither is +// written anywhere else in product code; +// - every `importKey` / `deriveKey` call passes the literal `false` as its +// `extractable` argument, and `deriveKey` asks for exactly one usage; +// - there is exactly one `encrypt` call and one `decrypt` call. +// +// And `package.json` names no cryptography library: Web Crypto only. +// +// What it CANNOT see: a name assembled at run time, code in a dependency, and +// whether the values passed at run time are the ones the format fixes - the +// unit tests in `src/model/shareProtected.test.ts` check those against an +// independent implementation. Test files are not product code and are not +// scanned. The check FAILS CLOSED: a file that does not parse, a missing +// module, or a module with no key derivation in it are errors. +import fs from 'node:fs' +import path from 'node:path' +import ts from 'typescript' +import { fileURLToPath } from 'node:url' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const SRC = path.join(ROOT, 'src') +const MODULE = 'src/model/shareProtected.ts' +const DIGEST_ONLY = 'src/model/workspace.ts' +const RANDOM_IDS = 'src/model/revision.ts' +const ITERATIONS = 600000 +const FORMAT_ID = 'loop-share-protected/1' + +const KEY_OPS = new Set(['deriveKey', 'deriveBits', 'importKey', 'exportKey', 'generateKey', 'wrapKey', 'unwrapKey', 'encrypt', 'decrypt']) +const CRYPTO_LIBRARY = /(^|[-_/@.])(crypto|cryptojs|argon2?|scrypt|bcrypt(js)?|sodium|libsodium|nacl|tweetnacl|forge|sjcl|noble|aes|pbkdf2?|hash-wasm|openpgp|jose|webcrypto)([-_/@.]|$)/i + +const CODE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/ +const TEST = /\.test\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/ +const KIND = { ts: ts.ScriptKind.TS, mts: ts.ScriptKind.TS, cts: ts.ScriptKind.TS, tsx: ts.ScriptKind.TSX, js: ts.ScriptKind.JS, mjs: ts.ScriptKind.JS, cjs: ts.ScriptKind.JS, jsx: ts.ScriptKind.JSX } + +const files = [] +const walk = (dir) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, e.name) + if (e.isDirectory()) walk(abs) + else if (CODE.test(e.name) && !TEST.test(e.name) && !/\.d\.(ts|mts|cts)$/.test(e.name)) files.push(abs) + } +} +walk(SRC) + +const problems = [] +const violations = [] +const inModule = { iterations: 0, formatId: 0, deriveKey: 0, importKey: 0, encrypt: 0, decrypt: 0 } + +/** the property name a node is, when it is one: `x.name` or `x['name']` */ +function propertyName(n) { + if (ts.isIdentifier(n) && ts.isPropertyAccessExpression(n.parent) && n.parent.name === n) return n.text + if (ts.isStringLiteralLike(n) && ts.isElementAccessExpression(n.parent) && n.parent.argumentExpression === n) return n.text + return null +} +/** the call a property name is the callee of, else null */ +function callOf(n) { + const access = n.parent + const call = access?.parent + return call && ts.isCallExpression(call) && call.expression === access ? call : null +} + +for (const abs of files) { + const rel = path.relative(ROOT, abs).split(path.sep).join('/') + const sf = ts.createSourceFile(rel, fs.readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true, KIND[rel.split('.').pop()]) + if (sf.parseDiagnostics?.length) { + problems.push(`${rel}: did not parse cleanly - refusing to report it as clean`) + continue + } + const at = (n) => `${rel}:${sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1}` + const isModule = rel === MODULE + const visit = (n) => { + // a module specifier: `import ... from 'x'`, `export ... from 'x'`, `import('x')` + if (ts.isStringLiteralLike(n)) { + const p = n.parent + const specifier = + ((ts.isImportDeclaration(p) || ts.isExportDeclaration(p)) && p.moduleSpecifier === n) || + (ts.isCallExpression(p) && p.expression.kind === ts.SyntaxKind.ImportKeyword && p.arguments[0] === n) + if (specifier && (n.text.startsWith('node:') || n.text === 'crypto')) violations.push(`${at(n)} import of '${n.text}' (product code uses Web Crypto, through the module)`) + if (n.text === FORMAT_ID) { + if (isModule) inModule.formatId++ + else violations.push(`${at(n)} '${FORMAT_ID}' written outside the module`) + } + } + if (ts.isNumericLiteral(n) && Number(n.text.replace(/_/g, '')) === ITERATIONS) { + if (isModule) inModule.iterations++ + else violations.push(`${at(n)} ${n.text} (the iteration count is written once, in the module)`) + } + const name = propertyName(n) + const bare = ts.isIdentifier(n) ? n.text : ts.isStringLiteralLike(n) ? n.text : null + if (name && KEY_OPS.has(name)) { + if (!isModule) violations.push(`${at(n)} ${name}`) + else { + const call = callOf(n) + if (name === 'exportKey') problems.push(`${at(n)}: exportKey - the key is never exported`) + if (call && name in inModule) inModule[name]++ + if (call && (name === 'importKey' || name === 'deriveKey')) { + const extractable = call.arguments[3] + if (!extractable || extractable.kind !== ts.SyntaxKind.FalseKeyword) problems.push(`${at(n)}: ${name} must pass the literal false as its extractable argument`) + } + if (call && name === 'deriveKey') { + const usages = call.arguments[4] + if (!usages || !ts.isArrayLiteralExpression(usages) || usages.elements.length !== 1) problems.push(`${at(n)}: deriveKey must ask for exactly one key usage`) + } + } + } else if (bare && KEY_OPS.has(bare) && ts.isStringLiteralLike(n) && !isModule) { + // a string that IS the name, wherever it sits: `subtle['deriveKey']`, a constant holding it + violations.push(`${at(n)} '${bare}'`) + } + if (bare === 'subtle' && !isModule && rel !== DIGEST_ONLY) violations.push(`${at(n)} subtle`) + if (bare === 'getRandomValues' && !isModule && rel !== RANDOM_IDS) violations.push(`${at(n)} getRandomValues`) + if (isModule && ts.isIdentifier(n) && n.text === 'console') problems.push(`${at(n)}: console - the module reports nothing; the browser's error text differs between causes`) + ts.forEachChild(n, visit) + } + visit(sf) +} + +if (!fs.existsSync(path.join(ROOT, MODULE))) problems.push(`${MODULE}: the module does not exist`) +else { + if (inModule.deriveKey === 0) problems.push(`${MODULE}: no deriveKey call found - this check is looking at the wrong file`) + if (inModule.iterations !== 1) problems.push(`${MODULE}: the iteration count ${ITERATIONS} must be written exactly once (found ${inModule.iterations})`) + if (inModule.formatId !== 1) problems.push(`${MODULE}: '${FORMAT_ID}' must be written exactly once (found ${inModule.formatId})`) + if (inModule.encrypt !== 1 || inModule.decrypt !== 1) problems.push(`${MODULE}: expected exactly one encrypt call and one decrypt call (found ${inModule.encrypt} and ${inModule.decrypt})`) +} + +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) +const deps = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'].flatMap((k) => Object.keys(pkg[k] ?? {})) +for (const d of deps) if (CRYPTO_LIBRARY.test(d)) problems.push(`package.json: '${d}' looks like a cryptography library - protected links use Web Crypto only`) + +console.log(` scanned ${files.length} product source files and ${deps.length} package names`) +console.log(` module: ${MODULE} (deriveKey ${inModule.deriveKey}, importKey ${inModule.importKey}, encrypt ${inModule.encrypt}, decrypt ${inModule.decrypt}; iteration count written ${inModule.iterations}x, format identifier ${inModule.formatId}x)`) +for (const p of problems) console.error(` FAIL ${p}`) +if (violations.length) { + console.error(` FAIL ${violations.length} use(s) outside the module:`) + for (const v of violations) console.error(` ${v}`) +} +if (problems.length || violations.length) { + console.error(`\n Key handling and encryption have one module: ${MODULE}.`) + process.exit(1) +} +console.log(' ok key handling and encryption stay in the module, with fixed parameters') diff --git a/src/model/shareProtected.test.ts b/src/model/shareProtected.test.ts new file mode 100644 index 00000000..fd566da2 --- /dev/null +++ b/src/model/shareProtected.test.ts @@ -0,0 +1,319 @@ +import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from 'node:crypto' +import { deflateSync, inflateSync } from 'node:zlib' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SHARE_MAX_BYTES, base64urlEncode } from './share' +import { + PROTECTED_FORMAT_ID, + PROTECTED_KDF_ITERATIONS, + PROTECTED_MAX_PAYLOAD_CHARS, + PROTECTED_MIN_PAYLOAD_CHARS, + PROTECTED_PREFIX, + ProtectedShareError, + openProtectedBytes, + passwordLength, + passwordRule, + protectedShareAvailable, + readProtectedPayload, + sealShareText, +} from './shareProtected' + +// loop-share-protected/1 is checked against an INDEPENDENT implementation: the +// helpers below use node:crypto and node:zlib and their own base64url, and +// share no code with the product module. Each side must open what the other +// sealed, so the byte layout, the AAD, the iteration count and the NFC rule are +// pinned from the outside - and product code needs no way to be handed a salt +// or an IV. + +const utf8 = (s: string) => new TextEncoder().encode(s) +const text = (b: Uint8Array) => new TextDecoder().decode(b) +const hex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('') +function cat(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)) + let o = 0 + for (const p of parts) { + out.set(p, o) + o += p.length + } + return out +} +function b64u(bytes: Uint8Array): string { + let bin = '' + for (const x of bytes) bin += String.fromCharCode(x) + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} +const unb64u = (s: string) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)) + +type Params = { aad?: string | null; iterations?: number } + +function nodeSeal(plain: Uint8Array, password: string, salt: Uint8Array, iv: Uint8Array, p: Params = {}): string { + const key = pbkdf2Sync(utf8(password.normalize('NFC')), salt, p.iterations ?? 600000, 32, 'sha256') + const c = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) + if (p.aad !== null) c.setAAD(utf8(p.aad ?? 'loop-share-protected/1')) + const ct = cat(c.update(plain), c.final()) + return b64u(cat(salt, iv, ct, c.getAuthTag())) +} + +function nodeOpen(payload: string, password: string): string { + const b = unb64u(payload) + const key = pbkdf2Sync(utf8(password.normalize('NFC')), b.subarray(0, 16), 600000, 32, 'sha256') + const d = createDecipheriv('aes-256-gcm', key, b.subarray(16, 28), { authTagLength: 16 }) + d.setAAD(utf8('loop-share-protected/1')) + d.setAuthTag(b.subarray(b.length - 16)) + return text(inflateSync(cat(d.update(b.subarray(28, b.length - 16)), d.final()))) +} + +const JSON_TEXT = '{"schema":"loop-studio/graph","version":1,"nodes":[],"edges":[]}' +const PASSWORD = 'correct horse battery staple' +const seal = (doc: string, password = PASSWORD, p: Params = {}) => + nodeSeal(deflateSync(utf8(doc)), password, randomBytes(16), randomBytes(12), p) +const open = (payload: string, password = PASSWORD) => openProtectedBytes(readProtectedPayload(payload), password) + +/** the error a promise rejects with */ +async function failure(p: Promise): Promise { + try { + await p + } catch (e) { + expect(e).toBeInstanceOf(ProtectedShareError) + return e as ProtectedShareError + } + throw new Error('expected a rejection') +} +/** everything about an error that could tell two causes apart */ +const face = (e: ProtectedShareError) => ({ + name: e.name, + message: e.message, + reason: e.reason, + cause: (e as { cause?: unknown }).cause, + keys: Object.keys(e).sort(), +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('the pinned constants', () => { + it('prefix, identifier, iteration count and bounds', () => { + expect(PROTECTED_PREFIX).toBe('p1=') + expect(PROTECTED_FORMAT_ID).toBe('loop-share-protected/1') + expect(PROTECTED_KDF_ITERATIONS).toBe(600000) + expect(PROTECTED_MIN_PAYLOAD_CHARS).toBe(60) + expect(PROTECTED_MAX_PAYLOAD_CHARS).toBe(SHARE_MAX_BYTES) + expect(PROTECTED_MAX_PAYLOAD_CHARS).toBe(8192) + }) +}) + +describe('known answer (made with node:crypto, fixed salt and IV)', () => { + // salt 00..0f, iv a0..ab, the password and JSON above, zlib default level + const KAT = + 'AAECAwQFBgcICQoLDA0OD6ChoqOkpaanqKmqq9PyGNZRewug-mdGAFZfxHzXjsIb0FFAeYydCqMS_nEtA-vbA5mP6DAdST51g7zuZ6-6wrsnUiGAb8oALBa6Bcuaj-G48CwYYw5iJ6d5PPVB2iAd' + + it('the product opens the fixed vector', async () => { + expect(await open(KAT)).toBe(JSON_TEXT) + }) + + it('the vector carries its salt and IV first, in the clear', () => { + const b = readProtectedPayload(KAT) + expect(hex(b.subarray(0, 16))).toBe('000102030405060708090a0b0c0d0e0f') + expect(hex(b.subarray(16, 28))).toBe('a0a1a2a3a4a5a6a7a8a9aaab') + }) +}) + +describe('both directions against the independent implementation', () => { + it('what the product seals, node:crypto opens', async () => { + const { payload, bytes } = await sealShareText(JSON_TEXT, PASSWORD) + expect(bytes).toBe(payload.length) + expect(nodeOpen(payload, PASSWORD)).toBe(JSON_TEXT) + }) + + it('what node:crypto seals, the product opens', async () => { + expect(await open(seal(JSON_TEXT))).toBe(JSON_TEXT) + }) + + it('Hangul, Thai, Cyrillic and emoji content survives in both directions', async () => { + const doc = JSON.stringify({ label: '재고 창고 🔑', note: 'ระดับ 15 · уровень' }) + expect(nodeOpen((await sealShareText(doc, PASSWORD)).payload, PASSWORD)).toBe(doc) + expect(await open(seal(doc))).toBe(doc) + }) + + it('the payload is unpadded base64url of salt + IV + ciphertext + tag: 44 bytes over the sealed text', async () => { + const { payload } = await sealShareText(JSON_TEXT, PASSWORD) + expect(payload).toMatch(/^[A-Za-z0-9_-]+$/) + const sealed = unb64u(payload) + expect(payload.length).toBe(Math.ceil((sealed.length * 4) / 3)) + // the same text sealed by the independent side differs only by its own deflate output + const reference = unb64u(seal(JSON_TEXT)) + expect(reference.length - deflateSync(utf8(JSON_TEXT)).length).toBe(44) + expect(sealed.length).toBeGreaterThan(44 + 8) + }) + + it('two links for the same document and password differ in salt, IV and ciphertext', async () => { + const a = unb64u((await sealShareText(JSON_TEXT, PASSWORD)).payload) + const b = unb64u((await sealShareText(JSON_TEXT, PASSWORD)).payload) + expect(hex(a.subarray(0, 16))).not.toBe(hex(b.subarray(0, 16))) + expect(hex(a.subarray(16, 28))).not.toBe(hex(b.subarray(16, 28))) + expect(hex(a.subarray(28))).not.toBe(hex(b.subarray(28))) + }) +}) + +describe('the format is bound: other parameters do not open (mutations of the independent side)', () => { + it.each([ + ['another format identifier as AAD', { aad: 'loop-share-protected/2' }], + ['no AAD at all', { aad: null }], + ['one iteration fewer', { iterations: 599999 }], + ['the previous OWASP count', { iterations: 310000 }], + ] as const)('%s => auth', async (_name, p) => { + expect((await failure(open(seal(JSON_TEXT, PASSWORD, p)))).reason).toBe('auth') + }) +}) + +describe('a wrong password and every kind of damage are ONE error', () => { + it('same name, message, reason, no cause, no extra field - and nothing on the console', async () => { + const logs = (['log', 'info', 'warn', 'error', 'debug'] as const).map((m) => vi.spyOn(console, m).mockImplementation(() => {})) + const good = seal(JSON_TEXT) + const bytes = () => readProtectedPayload(good) + const flip = (i: number) => { + const b = bytes() + b[i < 0 ? b.length + i : i] ^= 1 + return b + } + const cases: Record Promise> = { + 'wrong password': () => openProtectedBytes(bytes(), 'correct horse battery stapl3'), + 'password with a trailing space': () => openProtectedBytes(bytes(), PASSWORD + ' '), + 'salt bit': () => openProtectedBytes(flip(0), PASSWORD), + 'iv bit': () => openProtectedBytes(flip(16), PASSWORD), + 'ciphertext bit': () => openProtectedBytes(flip(30), PASSWORD), + 'tag bit': () => openProtectedBytes(flip(-1), PASSWORD), + 'one byte cut off': () => openProtectedBytes(bytes().subarray(0, bytes().length - 1), PASSWORD), + // the browser reports THIS one with a different message; it must not show + 'cut below the tag length': () => openProtectedBytes(bytes().subarray(0, 28 + 5), PASSWORD), + 'empty password': () => openProtectedBytes(bytes(), ''), + 'a 129 code point password': () => openProtectedBytes(bytes(), 'x'.repeat(129)), + } + const faces: Record> = {} + for (const [name, run] of Object.entries(cases)) faces[name] = face(await failure(run())) + const first = faces['wrong password'] + expect(first).toEqual({ name: 'ProtectedShareError', message: 'auth', reason: 'auth', cause: undefined, keys: ['name', 'reason'] }) + for (const [name, f] of Object.entries(faces)) expect(f, name).toEqual(first) + for (const l of logs) expect(l).not.toHaveBeenCalled() + }) +}) + +describe('the structure check runs before any password (no key is derived)', () => { + it('59 characters is too short, 60 is the first length that reaches the password', async () => { + const derive = vi.spyOn(crypto.subtle, 'deriveKey') + const sixty = base64urlEncode(new Uint8Array(45)) + expect(sixty.length).toBe(60) + expect(() => readProtectedPayload(sixty.slice(0, 59))).toThrow(ProtectedShareError) + expect(readProtectedPayload(sixty).length).toBe(45) + expect(derive).not.toHaveBeenCalled() + expect((await failure(open(sixty))).reason).toBe('auth') + expect(derive).toHaveBeenCalledTimes(1) + }) + + it('8,192 characters is inside the bound, 8,193 is not', () => { + expect(readProtectedPayload('A'.repeat(8192)).length).toBe(6144) + expect(() => readProtectedPayload('A'.repeat(8193))).toThrow(ProtectedShareError) + }) + + it.each([ + ['empty', ''], + ['padding', 'A'.repeat(62) + '=='], + ['a standard-base64 character', 'A'.repeat(63) + '+'], + ['whitespace', 'A'.repeat(40) + ' ' + 'A'.repeat(40)], + ['an impossible length', 'A'.repeat(61)], + ])('%s => structure', (_name, payload) => { + let reason: string | null = null + try { + readProtectedPayload(payload) + } catch (e) { + reason = (e as ProtectedShareError).reason + } + expect(reason).toBe('structure') + }) +}) + +describe('content that authenticates and is not a share payload', () => { + it('sealed bytes that are not a zlib stream => content, not auth', async () => { + const payload = nodeSeal(utf8('not a zlib stream at all'), PASSWORD, randomBytes(16), randomBytes(12)) + expect((await failure(open(payload))).reason).toBe('content') + }) + + it('a sealed decompression bomb is stopped at the 1 MiB cap => content', async () => { + const bomb = nodeSeal(deflateSync(new Uint8Array(2 * 1024 * 1024)), PASSWORD, randomBytes(16), randomBytes(12)) + expect(bomb.length).toBeLessThan(PROTECTED_MAX_PAYLOAD_CHARS) + expect((await failure(open(bomb))).reason).toBe('content') + }) +}) + +describe('the password rule', () => { + it('counts code points after NFC: a jamo-typed syllable and an emoji are one each', () => { + expect(passwordLength('비밀번호 🔑 열두 글자')).toBe(12) + expect(passwordLength('비밀번호 🔑 열두 글자'.normalize('NFD'))).toBe(12) + expect('비밀번호 🔑 열두 글자'.length).toBe(13) // UTF-16 units would disagree + expect(passwordLength(' two spaces ')).toBe(13) // whitespace counts + }) + + it('creating needs 12 to 128; sealing refuses anything else', async () => { + expect(passwordRule('x'.repeat(11))).toBe('too-short') + expect(passwordRule('x'.repeat(12))).toBe('ok') + expect(passwordRule('x'.repeat(128))).toBe('ok') + expect(passwordRule('x'.repeat(129))).toBe('too-long') + expect((await failure(sealShareText(JSON_TEXT, 'x'.repeat(11)))).reason).toBe('password') + expect((await failure(sealShareText(JSON_TEXT, 'x'.repeat(129)))).reason).toBe('password') + }) + + it('opening accepts a 1 code point password (a link from another conforming producer)', async () => { + expect(await open(seal(JSON_TEXT, 'x'), 'x')).toBe(JSON_TEXT) + }) + + it('NFD and NFC spellings of one password are the same key', async () => { + const nfc = '비밀번호열두글자이상입니다' + const { payload } = await sealShareText(JSON_TEXT, nfc.normalize('NFD')) + expect(await open(payload, nfc)).toBe(JSON_TEXT) + expect(nodeOpen(payload, nfc)).toBe(JSON_TEXT) + }) + + it('leading and trailing whitespace is part of the password, never trimmed', async () => { + const { payload } = await sealShareText(JSON_TEXT, ' twelve chars long ') + expect(await open(payload, ' twelve chars long ')).toBe(JSON_TEXT) + expect((await failure(open(payload, 'twelve chars long'))).reason).toBe('auth') + }) +}) + +describe('the key is handled with the least it needs', () => { + it('never extractable; encrypt-only when sealing, decrypt-only when opening; 600,000 x SHA-256', async () => { + const importKey = vi.spyOn(crypto.subtle, 'importKey') + const deriveKey = vi.spyOn(crypto.subtle, 'deriveKey') + const { payload } = await sealShareText(JSON_TEXT, PASSWORD) + await open(payload) + expect(importKey).toHaveBeenCalledTimes(2) + for (const call of importKey.mock.calls) { + expect(call[0]).toBe('raw') + expect(call[2]).toBe('PBKDF2') + expect(call[3]).toBe(false) + expect(call[4]).toEqual(['deriveKey']) + } + expect(deriveKey).toHaveBeenCalledTimes(2) + const [sealCall, openCall] = deriveKey.mock.calls + for (const call of [sealCall, openCall]) { + expect(call[0]).toMatchObject({ name: 'PBKDF2', hash: 'SHA-256', iterations: 600000 }) + expect(call[2]).toEqual({ name: 'AES-GCM', length: 256 }) + expect(call[3]).toBe(false) + } + expect(sealCall[4]).toEqual(['encrypt']) + expect(openCall[4]).toEqual(['decrypt']) + }) +}) + +describe('without Web Crypto', () => { + it('reports unavailable, and neither seals nor opens', async () => { + const bytes = readProtectedPayload(seal(JSON_TEXT)) // the structure check itself needs no crypto + vi.stubGlobal('crypto', {}) + expect(protectedShareAvailable()).toBe(false) + expect((await failure(sealShareText(JSON_TEXT, PASSWORD))).reason).toBe('unavailable') + expect((await failure(openProtectedBytes(bytes, PASSWORD))).reason).toBe('unavailable') + vi.unstubAllGlobals() + expect(protectedShareAvailable()).toBe(true) + }) +}) diff --git a/src/model/shareProtected.ts b/src/model/shareProtected.ts new file mode 100644 index 00000000..15016040 --- /dev/null +++ b/src/model/shareProtected.ts @@ -0,0 +1,206 @@ +// Protected share link - the sealed transport (SEMANTICS-P.md, loop-share-protected/1). +// +// A protected link is `#p1=`: +// +// payload = base64url( salt[16] | iv[12] | ciphertext | tag[16] ) +// plaintext = the zlib-deflated UTF-8 Graph JSON - exactly the bytes a plain +// `g1` link base64url-encodes (compress first: ciphertext does not +// compress, and the decompression-bomb cap still runs afterwards) +// key = PBKDF2-HMAC-SHA-256( UTF-8( NFC(password) ), salt, 600000 ) +// -> AES-256-GCM, never extractable, one usage only +// AAD = UTF-8 "loop-share-protected/1" +// +// There is no header byte: the `p1` prefix is the protocol header and the AAD +// binds it into the authentication, so a link re-labelled as another version or +// algorithm does not open. NOTHING in the link selects a parameter - the +// iteration count, the algorithm and the lengths are fixed by the format, and +// changing any of them needs a new prefix. +// +// This file is the ONLY place in product code that derives a key or encrypts +// or decrypts (`npm run check:share-crypto`). Web Crypto only - no cryptographic +// code is written or bundled here. It has no `console` call: the browser's own +// error message differs between a wrong password and data that is too short, +// so every failure leaves this module as a typed `ProtectedShareError` carrying +// a reason and nothing else. +// +// The password is an argument and is never kept: not in a module variable, not +// in a store, not in storage, not in the link. + +import { + SHARE_MAX_BYTES, + SHARE_MAX_DECODED_BYTES, + base64urlDecode, + base64urlEncode, + utf8Bytes, + utf8Decode, + zlibDeflate, + zlibInflate, +} from './share' + +/** fragment key: `p` = protected, `1` = payload format (SS P1) */ +export const PROTECTED_PREFIX = 'p1=' +/** the format identifier; its UTF-8 bytes are the AES-GCM additional authenticated data */ +export const PROTECTED_FORMAT_ID = 'loop-share-protected/1' +/** fixed by the format, never read from a link (SS P2) */ +export const PROTECTED_KDF_ITERATIONS = 600000 +export const PROTECTED_SALT_BYTES = 16 +export const PROTECTED_IV_BYTES = 12 +export const PROTECTED_TAG_BYTES = 16 +/** salt + iv + tag + one byte of ciphertext, as base64url characters */ +export const PROTECTED_MIN_PAYLOAD_CHARS = 60 +/** the same outbound cap as a plain link, and the inbound bound checked before any prompt */ +export const PROTECTED_MAX_PAYLOAD_CHARS = SHARE_MAX_BYTES +/** password length, in code points after NFC normalisation */ +export const PROTECTED_PASSWORD_MIN = 12 +export const PROTECTED_PASSWORD_MAX = 128 + +export type ProtectedFailure = + | 'unavailable' // no Web Crypto here (not a secure context, or an old browser) + | 'password' // creating only: the password breaks the 12..128 rule + | 'structure' // not a well-formed `p1` payload - decided BEFORE any password is asked for + | 'auth' // wrong password, failed authentication, damaged ciphertext: indistinguishable + | 'content' // authenticated, but what was sealed is not a share payload + +export class ProtectedShareError extends Error { + reason: ProtectedFailure + constructor(reason: ProtectedFailure) { + super(reason) + this.name = 'ProtectedShareError' + this.reason = reason + } +} + +const HEAD_BYTES = PROTECTED_SALT_BYTES + PROTECTED_IV_BYTES +const AAD = utf8Bytes(PROTECTED_FORMAT_ID) + +// TS lib variance: a `Uint8Array` view is a valid `BufferSource` at run time +// (same cast shape as `streamThrough` in `share.ts`). +const bs = (u: Uint8Array): BufferSource => u as unknown as BufferSource + +type CryptoLike = { subtle?: SubtleCrypto; getRandomValues?: Crypto['getRandomValues'] } + +function webCrypto(): { subtle: SubtleCrypto; random: (n: number) => Uint8Array } | null { + const c = (globalThis as { crypto?: CryptoLike }).crypto + const subtle = c?.subtle + if (!c || !subtle || typeof subtle.deriveKey !== 'function' || typeof c.getRandomValues !== 'function') return null + const fill = c.getRandomValues.bind(c) + return { subtle, random: (n) => fill(new Uint8Array(n)) } +} + +/** Can this page create and open protected links at all? `false` outside a + * secure context (no `crypto.subtle`) - there is no fallback (SS P7). */ +export function protectedShareAvailable(): boolean { + return webCrypto() !== null +} + +/** A password's length the way the rule counts it: code points after NFC, so a + * Hangul syllable typed as jamo and an emoji each count once (SS P3). */ +export function passwordLength(password: string): number { + return Array.from(password.normalize('NFC')).length +} + +/** The creating rule: 12 to 128. Whitespace is part of the password. */ +export function passwordRule(password: string): 'ok' | 'too-short' | 'too-long' { + const n = passwordLength(password) + if (n < PROTECTED_PASSWORD_MIN) return 'too-short' + return n > PROTECTED_PASSWORD_MAX ? 'too-long' : 'ok' +} + +async function deriveAesKey( + subtle: SubtleCrypto, + password: string, + salt: Uint8Array, + usage: 'encrypt' | 'decrypt', +): Promise { + const material = await subtle.importKey('raw', bs(utf8Bytes(password.normalize('NFC'))), 'PBKDF2', false, ['deriveKey']) + return subtle.deriveKey( + { name: 'PBKDF2', hash: 'SHA-256', salt: bs(salt), iterations: PROTECTED_KDF_ITERATIONS }, + material, + { name: 'AES-GCM', length: 256 }, + false, + [usage], + ) +} + +/** + * `text` (a Graph JSON string) + password -> the `p1` payload and its length. + * The caller enforces the outbound cap on `bytes`, exactly as for a plain link. + * Throws `ProtectedShareError`: `unavailable` or `password`. + */ +export async function sealShareText(text: string, password: string): Promise<{ payload: string; bytes: number }> { + const wc = webCrypto() + if (!wc) throw new ProtectedShareError('unavailable') + if (passwordRule(password) !== 'ok') throw new ProtectedShareError('password') + const plain = await zlibDeflate(utf8Bytes(text)) + let sealed: Uint8Array + const salt = wc.random(PROTECTED_SALT_BYTES) + const iv = wc.random(PROTECTED_IV_BYTES) + try { + const key = await deriveAesKey(wc.subtle, password, salt, 'encrypt') + sealed = new Uint8Array( + await wc.subtle.encrypt({ name: 'AES-GCM', iv: bs(iv), additionalData: bs(AAD), tagLength: PROTECTED_TAG_BYTES * 8 }, key, bs(plain)), + ) + } catch { + throw new ProtectedShareError('unavailable') + } + const out = new Uint8Array(HEAD_BYTES + sealed.length) + out.set(salt, 0) + out.set(iv, PROTECTED_SALT_BYTES) + out.set(sealed, HEAD_BYTES) + const payload = base64urlEncode(out) + return { payload, bytes: payload.length } +} + +/** + * The structure check that runs BEFORE any password is asked for (SS P5): the + * length bounds and the strict base64url alphabet. Returns the decoded bytes - + * the in-memory copy the caller keeps once the fragment is gone. Throws + * `ProtectedShareError('structure')` and nothing else. + */ +export function readProtectedPayload(payload: string): Uint8Array { + if (payload.length < PROTECTED_MIN_PAYLOAD_CHARS || payload.length > PROTECTED_MAX_PAYLOAD_CHARS) { + throw new ProtectedShareError('structure') + } + let bytes: Uint8Array + try { + bytes = base64urlDecode(payload) + } catch { + throw new ProtectedShareError('structure') + } + if (bytes.length <= HEAD_BYTES + PROTECTED_TAG_BYTES) throw new ProtectedShareError('structure') + return bytes +} + +/** + * Sealed bytes + password -> the decoded Graph JSON string (still un-parsed). + * + * `auth` covers every failure of the key derivation and the decryption with + * ONE reason - a wrong password, a changed salt, IV, ciphertext or tag, and a + * truncated link cannot be told apart, and whatever the browser reported is + * dropped here. `content` means the tag verified (the password was right) and + * the sealed bytes are not a bounded zlib stream. + */ +export async function openProtectedBytes(bytes: Uint8Array, password: string): Promise { + const wc = webCrypto() + if (!wc) throw new ProtectedShareError('unavailable') + let plain: Uint8Array + try { + const n = passwordLength(password) + if (n < 1 || n > PROTECTED_PASSWORD_MAX) throw new ProtectedShareError('auth') + const key = await deriveAesKey(wc.subtle, password, bytes.subarray(0, PROTECTED_SALT_BYTES), 'decrypt') + plain = new Uint8Array( + await wc.subtle.decrypt( + { name: 'AES-GCM', iv: bs(bytes.subarray(PROTECTED_SALT_BYTES, HEAD_BYTES)), additionalData: bs(AAD), tagLength: PROTECTED_TAG_BYTES * 8 }, + key, + bs(bytes.subarray(HEAD_BYTES)), + ), + ) + } catch { + throw new ProtectedShareError('auth') + } + try { + return utf8Decode(await zlibInflate(plain, SHARE_MAX_DECODED_BYTES)) + } catch { + throw new ProtectedShareError('content') + } +} diff --git a/src/test/nodeReference.d.ts b/src/test/nodeReference.d.ts new file mode 100644 index 00000000..4a4735fa --- /dev/null +++ b/src/test/nodeReference.d.ts @@ -0,0 +1,33 @@ +// The two Node modules the protected-share reference implementation uses in +// `src/model/shareProtected.test.ts`, declared by hand and only as far as that +// test needs them. +// +// Why not `@types/node`: adding it to the app's type program would make +// `Buffer`, `process` and every other Node global type-check in PRODUCT code, +// where none of them exists. These declarations add two importable module +// names and nothing global; `npm run check:share-crypto` forbids a `node:` +// import outside a test file. + +declare module 'node:crypto' { + interface GcmCipher { + setAAD(aad: Uint8Array): this + update(data: Uint8Array): Uint8Array + final(): Uint8Array + getAuthTag(): Uint8Array + } + interface GcmDecipher { + setAAD(aad: Uint8Array): this + setAuthTag(tag: Uint8Array): this + update(data: Uint8Array): Uint8Array + final(): Uint8Array + } + export function pbkdf2Sync(password: Uint8Array, salt: Uint8Array, iterations: number, keylen: number, digest: 'sha256'): Uint8Array + export function randomBytes(size: number): Uint8Array + export function createCipheriv(algorithm: 'aes-256-gcm', key: Uint8Array, iv: Uint8Array, options: { authTagLength: 16 }): GcmCipher + export function createDecipheriv(algorithm: 'aes-256-gcm', key: Uint8Array, iv: Uint8Array, options: { authTagLength: 16 }): GcmDecipher +} + +declare module 'node:zlib' { + export function deflateSync(data: Uint8Array): Uint8Array + export function inflateSync(data: Uint8Array): Uint8Array +} From ab8a227fa02e6b0f14cf280d79eee8a10e773626 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:48:22 +0900 Subject: [PATCH 2/8] feat(share): the fragment grammar and the opening flow of a protected link Part of #300. The second of five staged commits. The flow has no dialog yet, so at this commit a `#p1=` link waits for an answer nothing on screen can give; the dialogs follow in the next commit and the pull request ships as one. `classifyFragment` gains the protected grammar beside the plain one: `p1=` is a protected link, `p=` with another number is one from a newer version, and `p1` without `=` is a broken one. The plain `g1` rules and their order are unchanged. A build that predates this classifies all three as foreign and leaves them in the address bar, measured on the shipped v0.16.0 build. `src/store/protectedLink.ts` is the flow, in the fixed order: the structure check, the sealed bytes copied to memory and the fragment removed, the password prompt, key derivation, decryption, the bounded inflate, parse and validation, the replace confirmation, one load. A broken link shows a notice and loses its fragment without a password being asked for. A link from a newer version and a page without Web Crypto show a notice and keep their fragment, so the same address opens after an update or elsewhere. A wrong password leaves the prompt open with no limit; it and a damaged link write the same single console line. Content that authenticates and is not a diagram ends in a different notice with no retry. One derivation runs at a time, and a cancel during it drops its result. The password is an argument and is kept nowhere; the store holds a phase, a busy flag and a failure count. The promise `consumeShareLink` returns resolves only when the whole flow has ended, so the first-run Welcome card waits behind the prompt instead of covering it. The replace confirmation and the one load move to `src/store/shareApply.ts`, shared by both kinds of link; the plain path calls the same code in the same order. The boot module tells the storage gate about a waiting protected link as it does for a plain one. --- src/main.tsx | 9 +- src/model/share.test.ts | 28 ++- src/model/share.ts | 22 +++ src/model/shareProtected.ts | 6 +- src/store/protectedLink.test.ts | 296 ++++++++++++++++++++++++++++++++ src/store/protectedLink.ts | 197 +++++++++++++++++++++ src/store/shareApply.ts | 41 +++++ src/store/shareLink.ts | 41 ++--- 8 files changed, 609 insertions(+), 31 deletions(-) create mode 100644 src/store/protectedLink.test.ts create mode 100644 src/store/protectedLink.ts create mode 100644 src/store/shareApply.ts diff --git a/src/main.tsx b/src/main.tsx index 0bca4aae..ff1d53f1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -52,9 +52,12 @@ if (remembered !== null) { storageSession.use(remembered) void start() } else { - // a share link in the address is told to the gate: a temporary session keeps - // the shared document out of this browser's storage - const shareLinkWaiting = typeof location !== 'undefined' && classifyFragment(location.hash).kind === 'share' + // a share link in the address - plain (`g1`) or protected (`p1`) - is told to + // the gate: a temporary session keeps the shared document out of this + // browser's storage. The fragment itself is only classified here; it is read + // and tidied by the app, after the gate (issue #300). + const waiting = typeof location !== 'undefined' ? classifyFragment(location.hash).kind : 'foreign' + const shareLinkWaiting = waiting === 'share' || waiting === 'protected' void initI18n().then(() => { const host = document.getElementById('root')! const root = createRoot(host) diff --git a/src/model/share.test.ts b/src/model/share.test.ts index 2bd2020f..b47a77bb 100644 --- a/src/model/share.test.ts +++ b/src/model/share.test.ts @@ -553,12 +553,38 @@ describe('classifyFragment (SS U5.1 / U6)', () => { }) it('anything else ⇒ foreign (left in the address bar)', () => { - for (const h of ['', '#', '#section-2', '#/a/route', '#w1=abc', '#gg=1', '#g=1', '#g=', '#graph']) { + for (const h of ['', '#', '#section-2', '#/a/route', '#w1=abc', '#gg=1', '#g=1', '#g=', '#graph', '#p=1', '#p', '#pp1=abc', '#P1=abc', '#p2', '#page']) { expect(classifyFragment(h).kind).toBe('foreign') } }) }) +describe('classifyFragment - the protected grammar (SEMANTICS-P.md SS P4)', () => { + it('p1= ⇒ protected (payload may be empty), # optional', () => { + expect(classifyFragment('#p1=AbC-_')).toEqual({ kind: 'protected', payload: 'AbC-_' }) + expect(classifyFragment('p1=')).toEqual({ kind: 'protected', payload: '' }) + }) + + it('p=... with n ≠ 1 ⇒ protected-unsupported (a newer protected version)', () => { + for (const h of ['#p2=abc', 'p3=', '#p10=zzz', '#p99=AAAA']) { + expect(classifyFragment(h).kind).toBe('protected-unsupported') + } + }) + + it('starts p1 but is not p1=... ⇒ protected-malformed', () => { + for (const h of ['#p1', 'p1', '#p1x', '#p1-abc', '#p1/']) { + expect(classifyFragment(h).kind).toBe('protected-malformed') + } + }) + + it('the plain grammar is untouched by it', () => { + expect(classifyFragment('#g1=AbC').kind).toBe('share') + expect(classifyFragment('#g2=AbC').kind).toBe('unsupported') + expect(classifyFragment('#g1x').kind).toBe('malformed') + expect(readShareFragment('#p1=AbC')).toBeNull() + }) +}) + describe('encodeShareText / decodeShareText round-trip', () => { it('preserves a realistic graph document string exactly', async () => { const graph = JSON.stringify({ diff --git a/src/model/share.ts b/src/model/share.ts index dd6dfea1..38fba7a1 100644 --- a/src/model/share.ts +++ b/src/model/share.ts @@ -20,6 +20,11 @@ /** fragment key: `g` = graph, `1` = payload format (SS U1.2 / U11) */ export const SHARE_PREFIX = 'g1=' +/** fragment key of a PROTECTED link: `p` = protected, `1` = payload format + * (SEMANTICS-P.md, loop-share-protected/1). The sealed transport is + * `shareProtected.ts`; the key lives here because the fragment grammar has one + * classifier (`classifyFragment`), which the boot module also uses. */ +export const PROTECTED_SHARE_PREFIX = 'p1=' /** hard cap on the base64url payload byte length AFTER `#g1=` (SS U3.1 / U11) */ export const SHARE_MAX_BYTES = 8 * 1024 /** incremental cap on the inflated bytes; abort before JSON.parse (SS U3.2 / U11) */ @@ -662,11 +667,25 @@ export async function decodeShareText(payload: string): Promise { * * `unsupported` and `malformed` are Loop Studio's to clean up (warn + strip); * `foreign` is left untouched. + * + * The protected grammar (SEMANTICS-P.md SS P4) sits beside it: + * + * - `protected` - `p1=` : a protected link (payload may be '') + * - `protected-unsupported` - `p=...`, n != 1 : a protected link from a newer + * version. Its fragment is KEPT (unlike `g=`), so + * the same address opens after the app updates + * - `protected-malformed` - starts `p1` but is not `p1=...` : a broken protected link + * + * A build that predates `p1` classifies all three as `foreign` and leaves them + * in the address bar; that is what lets a protected link survive an update. */ export type FragmentKind = | { kind: 'share'; payload: string } | { kind: 'unsupported' } | { kind: 'malformed' } + | { kind: 'protected'; payload: string } + | { kind: 'protected-unsupported' } + | { kind: 'protected-malformed' } | { kind: 'foreign' } export function classifyFragment(hash: string): FragmentKind { @@ -674,6 +693,9 @@ export function classifyFragment(hash: string): FragmentKind { if (h.startsWith(SHARE_PREFIX)) return { kind: 'share', payload: h.slice(SHARE_PREFIX.length) } if (/^g\d+=/.test(h)) return { kind: 'unsupported' } // g2=, g10=, ... - a real versioned prefix if (/^g1(?![0-9])/.test(h)) return { kind: 'malformed' } // g1, g1x, g1-... (g1= handled above) + if (h.startsWith(PROTECTED_SHARE_PREFIX)) return { kind: 'protected', payload: h.slice(PROTECTED_SHARE_PREFIX.length) } + if (/^p\d+=/.test(h)) return { kind: 'protected-unsupported' } // p2=, p10=, ... + if (/^p1(?![0-9])/.test(h)) return { kind: 'protected-malformed' } // p1, p1x, p1-... return { kind: 'foreign' } } diff --git a/src/model/shareProtected.ts b/src/model/shareProtected.ts index 15016040..ebd92bd9 100644 --- a/src/model/shareProtected.ts +++ b/src/model/shareProtected.ts @@ -27,6 +27,7 @@ // in a store, not in storage, not in the link. import { + PROTECTED_SHARE_PREFIX, SHARE_MAX_BYTES, SHARE_MAX_DECODED_BYTES, base64urlDecode, @@ -37,8 +38,9 @@ import { zlibInflate, } from './share' -/** fragment key: `p` = protected, `1` = payload format (SS P1) */ -export const PROTECTED_PREFIX = 'p1=' +/** fragment key: `p` = protected, `1` = payload format (SS P1). Defined beside + * the fragment classifier in `share.ts`; re-exported here as the format's own. */ +export const PROTECTED_PREFIX = PROTECTED_SHARE_PREFIX /** the format identifier; its UTF-8 bytes are the AES-GCM additional authenticated data */ export const PROTECTED_FORMAT_ID = 'loop-share-protected/1' /** fixed by the format, never read from a link (SS P2) */ diff --git a/src/store/protectedLink.test.ts b/src/store/protectedLink.test.ts new file mode 100644 index 00000000..c3b9e6e8 --- /dev/null +++ b/src/store/protectedLink.test.ts @@ -0,0 +1,296 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { serialize } from '../model/serialize' +import type { LoopEdge, LoopNode } from '../model/types' +import { sealShareText } from '../model/shareProtected' +import { useGraphStore } from './graphStore' +import { useMcStore } from './mcStore' +import { + __resetProtectedLinkForTests, + cancelProtectedOpen, + dismissProtectedNotice, + submitProtectedPassword, + useProtectedLinkStore, +} from './protectedLink' +import { type ShareLoadOutcome, consumeShareLink } from './shareLink' +import { useSimStore } from './simStore' + +// SEMANTICS-P.md loop-share-protected/1 SS P6 - the opening flow, driven the way +// the app drives it: through `consumeShareLink`, with `hash` / `confirm` / +// `stripFragment` injected (node env: no window). + +const PASSWORD = 'correct horse battery staple' +const NEVER = (): never => { + throw new Error('must not be called') +} + +function docString(opts: { label?: string; runs?: number } = {}): string { + const nodes = [ + { id: 's', type: 'source', position: { x: 0, y: 0 }, data: { kind: 'source', label: opts.label ?? 'Shared' } }, + { id: 'p', type: 'pool', position: { x: 200, y: 0 }, data: { kind: 'pool', label: 'P', initial: 3 } }, + ] as unknown as LoopNode[] + const edges = [ + { id: 'e', source: 's', target: 'p', type: 'loop', data: { kind: 'resource', flow: '2' } }, + ] as unknown as LoopEdge[] + return serialize(nodes, edges, opts.runs ? { runs: opts.runs, steps: 9 } : undefined) +} +const protectedHash = async (text = docString(), password = PASSWORD) => `#p1=${(await sealShareText(text, password)).payload}` + +const phase = () => useProtectedLinkStore.getState().open +const rev = () => useGraphStore.getState().simulationRev +const nodeIds = () => useGraphStore.getState().nodes.map((n) => n.id).sort() + +/** start the flow; `settled` flips when the returned promise resolves */ +function start(hash: string, confirm: (m: string) => boolean = NEVER) { + const strip = vi.fn() + const result: { outcome: ShareLoadOutcome | null } = { outcome: null } + const done = consumeShareLink({ hash, confirm, stripFragment: strip }).then((o) => { + result.outcome = o + return o + }) + return { strip, result, done } +} +/** let already-resolved promises run their continuations */ +const tick = () => new Promise((r) => setTimeout(r, 0)) + +let warn: ReturnType + +beforeEach(() => { + __resetProtectedLinkForTests() + useMcStore.getState().clear() + useSimStore.getState().reset() + // a distinct, non-shared starting graph; `newGraph` also clears `pristineSample` + useGraphStore.getState().newGraph() + useGraphStore.getState().addNodeAt('gate', { x: 10, y: 10 }) + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('a link that is not asked a password for', () => { + it.each(['#p1', '#p1x', '#p1=', '#p1=AAAA', '#p1=' + 'A'.repeat(59), '#p1=' + 'A'.repeat(8193), '#p1=' + 'A'.repeat(63) + '+'])( + '%s ⇒ the damaged notice, the fragment removed, nothing touched', + async (hash) => { + const before = { ids: nodeIds(), rev: rev() } + const { strip, result, done } = start(hash) + await tick() + expect(phase()).toEqual({ phase: 'notice', notice: 'damaged' }) + expect(strip).toHaveBeenCalledTimes(1) + expect(result.outcome).toBeNull() // the flow is not over until the notice is closed + dismissProtectedNotice() + expect(await done).toEqual({ kind: 'failed', reason: 'protected-damaged' }) + expect(phase()).toEqual({ phase: 'idle' }) + expect({ ids: nodeIds(), rev: rev() }).toEqual(before) + expect(warn.mock.calls).toEqual([['Loop Studio: ignored a damaged protected share link.']]) + }, + ) + + it.each(['#p2=AAAA', '#p10=' + 'A'.repeat(80)])('%s ⇒ the newer-version notice, and the fragment is KEPT', async (hash) => { + const { strip, done } = start(hash) + await tick() + expect(phase()).toEqual({ phase: 'notice', notice: 'newer' }) + dismissProtectedNotice() + expect(await done).toEqual({ kind: 'failed', reason: 'protected-newer' }) + expect(strip).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('no Web Crypto ⇒ the requirement notice, the fragment KEPT, no prompt', async () => { + const hash = await protectedHash() + vi.stubGlobal('crypto', {}) + const { strip, done } = start(hash) + await tick() + expect(phase()).toEqual({ phase: 'notice', notice: 'unavailable' }) + dismissProtectedNotice() + expect(await done).toEqual({ kind: 'failed', reason: 'protected-unavailable' }) + expect(strip).not.toHaveBeenCalled() + }) + + it('a broken link is damaged even where Web Crypto is missing (the structure check needs none)', async () => { + vi.stubGlobal('crypto', {}) + const { strip, done } = start('#p1=AAAA') + await tick() + expect(phase()).toEqual({ phase: 'notice', notice: 'damaged' }) + dismissProtectedNotice() + await done + expect(strip).toHaveBeenCalledTimes(1) + }) +}) + +describe('a well-formed protected link', () => { + it('is copied to memory and its fragment removed BEFORE any password, and derives nothing yet', async () => { + const hash = await protectedHash() // sealing derives a key of its own: spy only afterwards + const derive = vi.spyOn(crypto.subtle, 'deriveKey') + const { strip, result } = start(hash) + await tick() + expect(phase()).toEqual({ phase: 'prompt', busy: false, failures: 0 }) + expect(strip).toHaveBeenCalledTimes(1) + expect(derive).not.toHaveBeenCalled() + expect(result.outcome).toBeNull() + cancelProtectedOpen() + }) + + it('the right password on the untouched sample ⇒ loaded with no confirm, exactly one bump, the run config applied', async () => { + useGraphStore.setState({ pristineSample: true }) + const { done } = start(await protectedHash(docString({ runs: 77 }))) + await tick() + const before = rev() + await submitProtectedPassword(PASSWORD) + expect(await done).toEqual({ kind: 'loaded' }) + expect(rev()).toBe(before + 1) + expect(nodeIds()).toEqual(['p', 's']) + expect(useMcStore.getState().config.runs).toBe(77) + expect(phase()).toEqual({ phase: 'idle' }) + expect(warn).not.toHaveBeenCalled() + }) + + it('a modified session asks before replacing: Cancel keeps everything, and the flow ends', async () => { + const before = { ids: nodeIds(), rev: rev() } + const confirm = vi.fn(() => false) + const { done } = start(await protectedHash(), confirm) + await tick() + await submitProtectedPassword(PASSWORD) + expect(await done).toEqual({ kind: 'cancelled' }) + expect(confirm).toHaveBeenCalledTimes(1) + expect({ ids: nodeIds(), rev: rev() }).toEqual(before) + expect(phase()).toEqual({ phase: 'idle' }) + }) + + it('a modified session, OK ⇒ a running sim is stopped first, then one load', async () => { + useSimStore.setState({ status: 'running' }, false) + const { done } = start(await protectedHash(), () => true) + await tick() + const before = rev() + await submitProtectedPassword(PASSWORD) + expect(await done).toEqual({ kind: 'loaded' }) + expect(useSimStore.getState().status).not.toBe('running') // pause() + loadDoc reset + expect(rev()).toBe(before + 1) + }) +}) + +describe('a wrong password', () => { + it('leaves the prompt open, counts the failure, touches nothing, and can be retried without limit', async () => { + useSimStore.setState({ status: 'running' }, false) + const before = { ids: nodeIds(), rev: rev() } + const { result, done } = start(await protectedHash(), () => true) + await tick() + for (let n = 1; n <= 3; n++) { + await submitProtectedPassword('not the password ' + n) + expect(phase()).toEqual({ phase: 'prompt', busy: false, failures: n }) + } + expect({ ids: nodeIds(), rev: rev() }).toEqual(before) + expect(useSimStore.getState().status).toBe('running') + expect(result.outcome).toBeNull() + await submitProtectedPassword(PASSWORD) + expect(await done).toEqual({ kind: 'loaded' }) + }) + + it('and a damaged link write the SAME console line, and nothing else', async () => { + const hash = await protectedHash() + // flip one character in the middle of the payload: still well-formed, no longer authentic + const i = 4 + 60 + const damaged = hash.slice(0, i) + (hash[i] === 'A' ? 'B' : 'A') + hash.slice(i + 1) + + start(hash) + await tick() + await submitProtectedPassword('not the password') + const wrongPassword = warn.mock.calls.slice() + cancelProtectedOpen() + warn.mockClear() + + start(damaged) + await tick() + await submitProtectedPassword(PASSWORD) + const damagedLink = warn.mock.calls.slice() + expect(phase()).toEqual({ phase: 'prompt', busy: false, failures: 1 }) + cancelProtectedOpen() + + expect(wrongPassword).toEqual([['Loop Studio: a protected share link was not opened; the password is wrong or the link is damaged.']]) + expect(damagedLink).toEqual(wrongPassword) + }) +}) + +describe('content that authenticates and is not a diagram', () => { + it('⇒ a different notice from the wrong-password one, no retry, nothing touched', async () => { + const before = { ids: nodeIds(), rev: rev() } + const { done } = start(await protectedHash('this is not a graph document')) + await tick() + await submitProtectedPassword(PASSWORD) + expect(phase()).toEqual({ phase: 'notice', notice: 'content' }) + await submitProtectedPassword(PASSWORD) // the prompt is gone: ignored + expect(phase()).toEqual({ phase: 'notice', notice: 'content' }) + dismissProtectedNotice() + expect(await done).toEqual({ kind: 'failed', reason: 'protected-content' }) + expect({ ids: nodeIds(), rev: rev() }).toEqual(before) + expect(warn.mock.calls).toEqual([['Loop Studio: a protected share link opened, but what it carries is not a diagram.']]) + }) +}) + +describe('one operation at a time, and cancel', () => { + it('a second submit while the first is deriving is ignored: one derivation, busy shown', async () => { + const hash = await protectedHash() + const derive = vi.spyOn(crypto.subtle, 'deriveKey') + useGraphStore.setState({ pristineSample: true }) + const { done } = start(hash) + await tick() + const first = submitProtectedPassword(PASSWORD) + expect(phase()).toEqual({ phase: 'prompt', busy: true, failures: 0 }) + const second = submitProtectedPassword('another attempt, typed fast') + await Promise.all([first, second]) + expect(derive).toHaveBeenCalledTimes(1) + expect(await done).toEqual({ kind: 'loaded' }) + }) + + it('Cancel on the prompt ⇒ cancelled, nothing touched; the link has to be opened again', async () => { + const before = { ids: nodeIds(), rev: rev() } + const { done } = start(await protectedHash()) + await tick() + cancelProtectedOpen() + expect(await done).toEqual({ kind: 'cancelled' }) + expect(phase()).toEqual({ phase: 'idle' }) + await submitProtectedPassword(PASSWORD) // nothing is open any more + expect({ ids: nodeIds(), rev: rev() }).toEqual(before) + }) + + it('Cancel DURING a derivation ⇒ its result is dropped: no load even with the right password', async () => { + useGraphStore.setState({ pristineSample: true }) + const before = { ids: nodeIds(), rev: rev() } + const { done } = start(await protectedHash()) + await tick() + const running = submitProtectedPassword(PASSWORD) + cancelProtectedOpen() + await running + expect(await done).toEqual({ kind: 'cancelled' }) + expect({ ids: nodeIds(), rev: rev() }).toEqual(before) + expect(phase()).toEqual({ phase: 'idle' }) + }) +}) + +describe('the password is not kept', () => { + it('nothing the prompt store holds contains it, during or after an attempt', async () => { + const secret = 'a very particular password 7391' + const seen: string[] = [] + const unsub = useProtectedLinkStore.subscribe((s) => seen.push(JSON.stringify(s))) + const { done } = start(await protectedHash(docString(), secret), () => true) + await tick() + await submitProtectedPassword('wrong ' + secret) + await submitProtectedPassword(secret) + await done + unsub() + seen.push(JSON.stringify(useProtectedLinkStore.getState())) + expect(seen.length).toBeGreaterThan(3) + for (const s of seen) expect(s).not.toContain('7391') + expect(warn.mock.calls.flat().join(' ')).not.toContain('7391') + }) +}) + +describe('the plain path is unchanged by the protected one', () => { + it('a foreign fragment is still none, and a `g2=` link is still stripped with its own warning', async () => { + expect(await consumeShareLink({ hash: '#section-2', confirm: NEVER, stripFragment: NEVER })).toEqual({ kind: 'none' }) + const strip = vi.fn() + expect(await consumeShareLink({ hash: '#g2=abc', confirm: NEVER, stripFragment: strip })).toEqual({ kind: 'failed', reason: 'unsupported-version' }) + expect(strip).toHaveBeenCalledTimes(1) + expect(phase()).toEqual({ phase: 'idle' }) + }) +}) diff --git a/src/store/protectedLink.ts b/src/store/protectedLink.ts new file mode 100644 index 00000000..3fb46b2f --- /dev/null +++ b/src/store/protectedLink.ts @@ -0,0 +1,197 @@ +// Protected share link - the opening flow (SEMANTICS-P.md, loop-share-protected/1). +// +// Fixed order (SS P6), after the #297 storage gate has let the app start: +// +// structure check -> sealed bytes copied to memory, fragment removed +// -> password prompt -> key derivation -> decrypt + authenticate +// -> bounded inflate -> parse + validate -> replace confirmation +// -> exactly one load +// +// Nothing before the load changes the graph, the sim or the MC state, and +// nothing from the shared document exists before a correct password: there is +// no plaintext to show. +// +// What this module holds: +// - the SEALED bytes, in a module variable, between the prompt opening and +// the flow ending. They are what was in the link; a reload drops them, and +// the link then has to be opened again (SS P4). +// - the prompt's UI state, in a store: a phase, a busy flag, a failure count. +// What it never holds: the password. `submitProtectedPassword` receives it as +// an argument, hands it to the sealed transport, and keeps no reference. +// +// Console output is one fixed line per outcome. A wrong password and a damaged +// link write the SAME line - the reason the browser gave is never printed. + +import { create } from 'zustand' +import { deserialize } from '../model/serialize' +import type { FragmentKind } from '../model/share' +import { ProtectedShareError, openProtectedBytes, protectedShareAvailable, readProtectedPayload } from '../model/shareProtected' +import { applySharedGraph, replaceConfirmed } from './shareApply' + +export type ProtectedNotice = + | 'damaged' // not a well-formed protected link; no password was asked for + | '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 + +export type ProtectedOpenState = + | { phase: 'idle' } + | { phase: 'notice'; notice: ProtectedNotice } + /** `failures` counts wrong-password answers; the dialog re-selects its input on each change */ + | { phase: 'prompt'; busy: boolean; failures: number } + +export type ProtectedOutcome = + | { kind: 'loaded' } + | { kind: 'cancelled' } + | { kind: 'failed'; reason: 'protected-damaged' | 'protected-unavailable' | 'protected-newer' | 'protected-content' } + +type ProtectedFragment = Extract + +export const useProtectedLinkStore = create<{ open: ProtectedOpenState }>(() => ({ open: { phase: 'idle' } })) + +const LINE = { + damaged: 'Loop Studio: ignored a damaged protected share link.', + unavailable: 'Loop Studio: a protected share link cannot be opened here; a current browser and HTTPS are required.', + 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.', +} as const + +const NOTICE_REASON = { + damaged: 'protected-damaged', + unavailable: 'protected-unavailable', + newer: 'protected-newer', + content: 'protected-content', +} as const + +/** the sealed bytes of the link being opened - memory only */ +let sealed: Uint8Array | null = null +/** bumped whenever the flow ends, so a derivation still running is ignored */ +let generation = 0 +/** one key derivation at a time; a plain variable, set and read synchronously */ +let deriving = false +let confirmReplace: ((message: string) => boolean) | undefined +/** resolves the promise `openProtectedLink` returned */ +let settle: ((outcome: ProtectedOutcome) => void) | null = null +/** the outcome a notice resolves with once it is dismissed */ +let noticeOutcome: ProtectedOutcome | null = null + +const setOpen = (open: ProtectedOpenState) => useProtectedLinkStore.setState({ open }) + +function end(outcome: ProtectedOutcome): void { + sealed = null + generation++ + noticeOutcome = null + setOpen({ phase: 'idle' }) + const done = settle + settle = null + done?.(outcome) +} + +function showNotice(notice: ProtectedNotice): void { + sealed = null + generation++ + console.warn(LINE[notice]) + noticeOutcome = { kind: 'failed', reason: NOTICE_REASON[notice] } + setOpen({ phase: 'notice', notice }) +} + +/** + * Start opening a protected fragment. Resolves when the whole flow has ended - + * loaded, cancelled, or a notice dismissed - so the caller can hold back what + * must come after it (the first-run Welcome card). + * + * `strip` removes the fragment from the address bar. It is called for a broken + * link and for a well-formed one (as soon as its bytes are in memory), and NOT + * for a link this build cannot open here: an unknown `p` and a page without + * Web Crypto both leave the address as it is, so the link can be opened again + * after an update or in another browser. + */ +export function openProtectedLink( + fragment: ProtectedFragment, + opts: { strip: () => void; confirm?: (message: string) => boolean }, +): Promise { + return new Promise((resolve) => { + if (settle) end({ kind: 'cancelled' }) // a second link while one is open: the first is dropped + settle = resolve + confirmReplace = opts.confirm + + if (fragment.kind === 'protected-unsupported') return showNotice('newer') + if (fragment.kind === 'protected-malformed') { + opts.strip() + return showNotice('damaged') + } + let bytes: Uint8Array + try { + bytes = readProtectedPayload(fragment.payload) + } catch { + opts.strip() + return showNotice('damaged') + } + if (!protectedShareAvailable()) return showNotice('unavailable') + + sealed = bytes + opts.strip() + setOpen({ phase: 'prompt', busy: false, failures: 0 }) + }) +} + +/** + * 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. + */ +export async function submitProtectedPassword(password: string): Promise { + const state = useProtectedLinkStore.getState().open + if (state.phase !== 'prompt' || deriving || !sealed) return + deriving = true + const mine = generation + setOpen({ phase: 'prompt', busy: true, failures: state.failures }) + try { + let text: string + try { + text = await openProtectedBytes(sealed, password) + } catch (e) { + if (mine !== generation) return // cancelled meanwhile: the result is dropped + const reason = e instanceof ProtectedShareError ? e.reason : 'auth' + if (reason === 'content') return showNotice('content') + if (reason === 'unavailable') return showNotice('unavailable') + console.warn(LINE.auth) + setOpen({ phase: 'prompt', busy: false, failures: state.failures + 1 }) + return + } + if (mine !== generation) return + let parsed: ReturnType + try { + parsed = deserialize(text) + } catch { + return showNotice('content') + } + if (!replaceConfirmed(confirmReplace)) return end({ kind: 'cancelled' }) + applySharedGraph(parsed) + end({ kind: 'loaded' }) + } finally { + deriving = false + } +} + +/** Cancel the prompt. The current document and any run stay as they are; a + * derivation still running is ignored when it finishes. */ +export function cancelProtectedOpen(): void { + if (useProtectedLinkStore.getState().open.phase === 'prompt') end({ kind: 'cancelled' }) +} + +/** Close a notice. */ +export function dismissProtectedNotice(): void { + if (useProtectedLinkStore.getState().open.phase === 'notice') end(noticeOutcome ?? { kind: 'cancelled' }) +} + +/** test seam: back to a cold module */ +export function __resetProtectedLinkForTests(): void { + sealed = null + generation++ + deriving = false + settle = null + noticeOutcome = null + confirmReplace = undefined + setOpen({ phase: 'idle' }) +} diff --git a/src/store/shareApply.ts b/src/store/shareApply.ts new file mode 100644 index 00000000..2a93b3d5 --- /dev/null +++ b/src/store/shareApply.ts @@ -0,0 +1,41 @@ +// Shareable URL - the two steps every kind of share link ends with +// (SEMANTICS-U.md SS U5.4 / U5.5; SEMANTICS-P.md SS P6 reuses them unchanged): +// the replace confirmation, then exactly one load. +// +// Kept apart from `shareLink.ts` so the plain (`g1`) and the protected (`p1`) +// paths call the same code and neither imports the other. + +import type { deserialize } from '../model/serialize' +import { t } from '../i18n' +import { useGraphStore } from './graphStore' +import { useMcStore } from './mcStore' +import { useSimStore } from './simStore' + +/** the replace-confirm text, in the active UI language (`share.replacePrompt`) */ +export const replacePrompt = (): string => t('share.replacePrompt') + +/** + * SS U5.4 - may the shared document replace the current one? The untouched + * first-boot sample is replaced without asking; anything else asks once. + * `confirm` defaults to `window.confirm`. + */ +export function replaceConfirmed(confirm?: (message: string) => boolean): boolean { + if (useGraphStore.getState().pristineSample) return true + const ask = confirm ?? (typeof window !== 'undefined' ? window.confirm : () => true) + return ask(replacePrompt()) +} + +/** SS U5.5 - stop any run, then exactly one `loadDoc`. */ +export function applySharedGraph(parsed: ReturnType): void { + useSimStore.getState().pause() // first point that run state changes + // The link is a whole GraphDoc (the Share encoder is `exportJSON`, which + // carries the saved `frames` + `dataImports`), so it REPLACES those too: + // `[]` when the link has none. Passing nothing here would mean "keep the + // current document's" (`loadDoc`'s revision-Apply posture), which carried + // the PREVIOUS document's frames and data-import records into the shared + // graph — and into its next Export / digest — while dropping the link's own. + useGraphStore + .getState() + .loadDoc({ nodes: parsed.nodes, edges: parsed.edges }, parsed.modelVersion, parsed.frames, parsed.dataImports) // the ONE bump + useMcStore.getState().applyRecommended(parsed.recommendedRunConfig) +} diff --git a/src/store/shareLink.ts b/src/store/shareLink.ts index e9cf73bd..88ea439b 100644 --- a/src/store/shareLink.ts +++ b/src/store/shareLink.ts @@ -20,19 +20,17 @@ import { classifyFragment, decodeShareText, } from '../model/share' -import { t } from '../i18n' -import { useGraphStore } from './graphStore' -import { useMcStore } from './mcStore' -import { useSimStore } from './simStore' +import { type ProtectedOutcome, openProtectedLink } from './protectedLink' +import { applySharedGraph, replaceConfirmed, replacePrompt } from './shareApply' -/** the replace-confirm text, in the active UI language (`share.replacePrompt`) */ -export const replacePrompt = (): string => t('share.replacePrompt') +export { replacePrompt } export type ShareLoadOutcome = | { kind: 'none' } // not a Loop Studio fragment - left in the address bar | { kind: 'loaded' } // a valid link was applied - | { kind: 'cancelled' } // a valid link, but the user declined the replace + | { kind: 'cancelled' } // a valid link, but the user declined the replace (or the password prompt) | { kind: 'failed'; reason: ShareFailure | 'bad-graph' | 'unsupported-version' | 'malformed' } + | Extract // a protected link that did not open (SEMANTICS-P.md) type Options = { /** defaults to `location.hash` */ @@ -64,6 +62,13 @@ export async function consumeShareLink(opts: Options = {}): Promise true) - if (!ask(replacePrompt())) { - strip() // Cancel: only the fragment goes; no run-stop, no bump - return { kind: 'cancelled' } - } + if (!replaceConfirmed(opts.confirm)) { + strip() // Cancel: only the fragment goes; no run-stop, no bump + return { kind: 'cancelled' } } // ---- apply (§U5.5): stop any run, then exactly one loadDoc ------------- - useSimStore.getState().pause() // first point that run state changes - // The link is a whole GraphDoc (the Share encoder is `exportJSON`, which - // carries the saved `frames` + `dataImports`), so it REPLACES those too: - // `[]` when the link has none. Passing nothing here would mean "keep the - // current document's" (`loadDoc`'s revision-Apply posture), which carried - // the PREVIOUS document's frames and data-import records into the shared - // graph — and into its next Export / digest — while dropping the link's own. - useGraphStore - .getState() - .loadDoc({ nodes: parsed.nodes, edges: parsed.edges }, parsed.modelVersion, parsed.frames, parsed.dataImports) // the ONE bump - useMcStore.getState().applyRecommended(parsed.recommendedRunConfig) + applySharedGraph(parsed) strip() return { kind: 'loaded' } From 13eb6f04fa3f8378affb09b463e8d72014037088 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:56:40 +0900 Subject: [PATCH 3/8] feat(share): the dialogs for creating and opening a protected link, in English and Korean Part of #300. The third of five staged commits. Alone it does not type-check and fails `check:i18n`: the 29 new strings exist in English and Korean only, and the other sixteen languages follow in the next commit. Creating. `ShareCreateDialog` replaces the plain confirm in the share flow, on desktop and on the phone alike. It is the same disclosure with one more choice, `Protect with a password`, unticked by default; the plain link behaves exactly as before. Ticked, it shows two password fields (`autocomplete="new-password"`), one show or hide control, the 12 to 128 rule, and four sentences: send the password another way, a lost password cannot be recovered, the link is only as strong as its password, and Loop Studio does not store or transmit the password or the key while a browser or a password manager may offer to save it. Without Web Crypto the choice is disabled and says why. While the key is derived the dialog stays open with a status line and everything disabled, and a second submit is ignored. Over the size cap, a missing public address and a failed rule are shown inside the dialog. The password stays in the two input elements, is read once on submit, and goes when the dialog closes; the link is what gets copied, never the password. The result panel says to send the password separately. Opening. `ProtectedLinkDialog` is mounted once at the root and draws the flow of the previous commit: the prompt with one password field (`autocomplete="current-password"`), a status line while the link opens, and a sentence saying whether the opened diagram is kept in this browser (personal session) or not (temporary session). After a wrong answer the field keeps what was typed, selected. A press on the backdrop does not dismiss the prompt. An Enter that only commits an input-method composition does not submit. The four notices (damaged, newer version, no Web Crypto, content that is not a diagram) are alert dialogs with one Close. `prepareProtectedShareLink` in `src/ui/shareAction.ts` is the one shared action behind both entry points: seal, the same cap as a plain link, the `#p1=` address on the fixed public base. Seen working on the dev server in throwaway profiles, desktop and a phone-sized viewport: a link is created and opened, a wrong password leaves the document untouched, the first-run Welcome card waits behind the prompt and appears after it. --- src/App.tsx | 2 + src/components/ProtectedLinkDialog.tsx | 185 +++++++++++++ src/components/Toolbar.tsx | 1 + src/components/mobile/MobileMoreMenu.tsx | 45 +++- src/components/toolbar/ShareCreateDialog.tsx | 262 +++++++++++++++++++ src/components/toolbar/ShareSurface.tsx | 21 +- src/components/toolbar/useShareSurface.ts | 33 ++- src/i18n/locales/en/ui.ts | 30 +++ src/i18n/locales/ko/ui.ts | 30 +++ src/index.css | 111 ++++++++ src/ui/shareAction.ts | 30 ++- 11 files changed, 729 insertions(+), 21 deletions(-) create mode 100644 src/components/ProtectedLinkDialog.tsx create mode 100644 src/components/toolbar/ShareCreateDialog.tsx diff --git a/src/App.tsx b/src/App.tsx index 9afff51b..5e8509a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { MobileInspectorSheet } from './components/mobile/MobileInspectorSheet' import { MobileRunBar } from './components/mobile/MobileRunBar' import { MonteCarloDialog } from './components/MonteCarloDialog' import { PlaybackAnnouncer } from './components/PlaybackAnnouncer' +import { ProtectedLinkDialog } from './components/ProtectedLinkDialog' import { PwaUpdateBar } from './components/PwaUpdateBar' import { ReviewOverlay } from './components/ReviewOverlay' import { ShareLoader } from './components/ShareLoader' @@ -40,6 +41,7 @@ export default function App() { + ) diff --git a/src/components/ProtectedLinkDialog.tsx b/src/components/ProtectedLinkDialog.tsx new file mode 100644 index 00000000..987f2719 --- /dev/null +++ b/src/components/ProtectedLinkDialog.tsx @@ -0,0 +1,185 @@ +import { useEffect, useId, useRef, useState } from 'react' +import type { KeyboardEvent, SyntheticEvent } from 'react' +import { useT, type MessageKey } from '../i18n' +import { PROTECTED_PASSWORD_MAX } from '../model/shareProtected' +import { + type ProtectedNotice, + cancelProtectedOpen, + dismissProtectedNotice, + submitProtectedPassword, + useProtectedLinkStore, +} from '../store/protectedLink' +import { selectTemporary, useSessionStore } from '../store/sessionStore' +import { DialogScrim } from './DialogScrim' +import { useDialogFocus } from './useDialogFocus' + +// Issue #300 — what a person sees when a protected share link is opened +// (SEMANTICS-P.md SS P6). One component for desktop and phone, mounted once at +// the app's root; the flow itself is `src/store/protectedLink.ts`. +// +// The prompt shows nothing derived from the link: there is no plaintext before +// a correct password, and the ciphertext's size is not shown either. +// +// The password is the 's own value and nowhere else. It is read on +// submit and handed to the flow; after a wrong answer it stays in the field, +// selected, so it can be retyped or corrected. Closing the dialog unmounts the +// field. Whether a browser or a password manager offers to fill or save it is +// theirs to decide (`autocomplete="current-password"`). + +const NOTICE_KEY = { + damaged: 'share.open.notice.damaged', + unavailable: 'share.open.notice.unavailable', + newer: 'share.open.notice.newer', + content: 'share.open.notice.content', +} as const satisfies Record + +export function ProtectedLinkDialog() { + const open = useProtectedLinkStore((s) => s.open) + if (open.phase === 'prompt') return + if (open.phase === 'notice') return + return null +} + +function Prompt({ busy, failures }: { busy: boolean; failures: number }) { + const t = useT() + const temporary = useSessionStore(selectTemporary) + const ref = useRef(null) + const inputRef = useRef(null) + const titleId = useId() + const bodyId = useId() + const errorId = useId() + const [shown, setShown] = useState(false) + const [empty, setEmpty] = useState(true) + + useDialogFocus(true, ref, cancelProtectedOpen) + // a wrong answer: the field keeps what was typed, selected, ready to be + // retyped or corrected + useEffect(() => { + if (failures === 0) return + const el = inputRef.current + el?.focus() + el?.select() + }, [failures]) + + const submit = (e: SyntheticEvent) => { + e.preventDefault() + const password = inputRef.current?.value ?? '' + if (password === '') return + void submitProtectedPassword(password) // one at a time: the flow ignores a second call + } + // Enter that only commits an IME composition must not submit the form + const guardComposition = (e: KeyboardEvent) => { + if (e.key === 'Enter' && (e.nativeEvent.isComposing || e.keyCode === 229)) e.preventDefault() + } + + return ( + +
e.stopPropagation()} + > +
+ {t('share.open.title')} +
+
+
+

+ {t('share.open.body')} +

+ + + {failures > 0 && !busy ? ( + + ) : null} +

+ {temporary ? t('share.open.note.temporary') : t('share.open.note.personal')} +

+

+ {busy ? t('share.open.busy') : ''} +

+
+
+ + +
+
+
+
+ ) +} + +function Notice({ notice }: { notice: ProtectedNotice }) { + const t = useT() + const ref = useRef(null) + const titleId = useId() + const bodyId = useId() + useDialogFocus(true, ref, dismissProtectedNotice) + return ( + +
e.stopPropagation()} + > +
+ {t('share.open.notice.title')} +
+
+

+ {t(NOTICE_KEY[notice])} +

+
+
+ +
+
+
+ ) +} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index b37bc72c..4849c3c4 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -619,6 +619,7 @@ export function Toolbar() { anchorRef={shareAnchorElRef} returnFocusTo={() => returnFocusElRef.current} onConfirm={share.confirm} + onConfirmProtected={share.confirmProtected} onCancel={share.cancel} onRetryCopy={share.retryCopy} onClosePanel={share.closePanel} diff --git a/src/components/mobile/MobileMoreMenu.tsx b/src/components/mobile/MobileMoreMenu.tsx index 98e2061a..de173a53 100644 --- a/src/components/mobile/MobileMoreMenu.tsx +++ b/src/components/mobile/MobileMoreMenu.tsx @@ -20,7 +20,7 @@ import { } from '../../store/workspaceIO' import { downloadText } from '../../ui/download' import { exportProjectRevision, makeProposal } from '../../ui/revisionActions' -import { prepareShareLink, shareKb } from '../../ui/shareAction' +import { type ProtectedShareLinkResult, prepareProtectedShareLink, prepareShareLink, shareKb } from '../../ui/shareAction' import { useTourStore } from '../../store/tourStore' import { useWhatsNewStore } from '../../store/whatsNewStore' import { useHintStore, useTier3Ready, useLargeGraphInteractionGate } from '../../store/hintStore' @@ -37,7 +37,7 @@ import { MobileSheet } from './MobileSheet' import { ThemeToggle } from '../ThemeToggle' import { WhatsNewPanel } from '../WhatsNewPanel' import { StoragePrivacyDialog } from '../StoragePrivacyDialog' -import { shareDisclosureBody } from '../toolbar/shareDisclosure' +import { ShareCreateDialog } from '../toolbar/ShareCreateDialog' import { selectTemporary, useSessionStore } from '../../store/sessionStore' import { ArrowIcon } from '../../ui/icons' @@ -112,7 +112,7 @@ export function MobileMoreMenu({ closeOverlay('more') } - const [sharePanel, setSharePanel] = useState<{ url: string; copied: boolean } | null>(null) + const [sharePanel, setSharePanel] = useState<{ url: string; copied: boolean; protected?: boolean } | null>(null) const [shareConfirm, setShareConfirm] = useState(false) const [shareBusy, setShareBusy] = useState(false) const [authorOpen, setAuthorOpen] = useState(false) @@ -157,6 +157,31 @@ export function MobileMoreMenu({ } } + // issue #300 — the protected link, through the same dialog and the same + // shared action as desktop. Anything but `ok` is shown inside the dialog; on + // `ok` the LINK is copied (never the password) and the result sheet opens. + const runProtectedShare = async (password: string): Promise => { + if (shareBusy) return { status: 'unavailable' } + setShareBusy(true) + try { + const result = await prepareProtectedShareLink(exportJSON({ ...useMcStore.getState().config }), password) + if (result.status !== 'ok') return result + let copied = false + try { + await navigator.clipboard.writeText(result.url) + copied = true + } catch { + copied = false + } + setShareConfirm(false) + setSharePanel({ url: result.url, copied, protected: true }) + openOverlay('share') + return result + } finally { + setShareBusy(false) + } + } + const doLoadTemplate = (id: string) => { const tpl = TEMPLATES.find((x) => x.id === id) if (!tpl) return @@ -401,12 +426,11 @@ export function MobileMoreMenu({ {__BUILD_SHA__ ? ` · ${__BUILD_SHA__}` : ''} - setShareConfirm(false)} returnFocusTo={() => document.querySelector('.sheet__row--first')} /> @@ -554,6 +578,11 @@ export function MobileMoreMenu({
{sharePanel.copied ? t('share.panel.copied') : t('share.panel.copyThis')}
+ {sharePanel.protected ? ( +
+ {t('share.panel.protected')} +
+ ) : null} `onCreatePlain`, exactly as before +// protect with a password -> two password fields, then `onCreateProtected` +// +// The password lives in the two elements and nowhere else: not in +// React state, not in a store. It is read once, on submit, handed to +// `onCreateProtected`, and the fields are emptied when the dialog closes. +// Whether a browser or a password manager offers to save it is theirs to +// decide (`autocomplete="new-password"`); the dialog says so. +// +// While the key is derived the dialog stays open with a status line and +// everything disabled; a result the caller cannot turn into a link (over the +// size cap, no public address, no Web Crypto) is shown here, inline. + +type Problem = + | { kind: 'short' | 'long' | 'mismatch' | 'unavailable' | 'no-base' } + | { kind: 'too-large'; bytes: number; cap: number } + +type Props = { + open: boolean + /** a temporary session adds its one sentence to the disclosure (issue #297) */ + temporary: boolean + onCancel: () => void + /** the plain link: the caller closes the dialog and does the rest, as before */ + onCreatePlain: () => void + /** seal + build + copy. `ok` means the caller has closed the dialog. */ + onCreateProtected: (password: string) => Promise + returnFocusTo?: () => HTMLElement | null | undefined +} + +const PROBLEM_KEY = { + short: 'share.protect.error.short', + long: 'share.protect.error.long', + mismatch: 'share.protect.error.mismatch', + unavailable: 'share.protect.unavailable', + 'no-base': 'share.noBase', +} as const satisfies Record + +// Closed = forgotten. The body is mounted only while the dialog is open, so the +// choice, both fields and any message go when it closes - nothing to reset. +export function ShareCreateDialog({ open, ...rest }: Props) { + return open ? : null +} + +function ShareCreateBody({ temporary, onCancel, onCreatePlain, onCreateProtected, returnFocusTo }: Omit) { + const t = useT() + const ref = useRef(null) + const cancelRef = useRef(null) + const passwordRef = useRef(null) + const confirmRef = useRef(null) + const titleId = useId() + const bodyId = useId() + const formId = useId() + const ruleId = useId() + const problemId = useId() + const [protect, setProtect] = useState(false) + const [shown, setShown] = useState(false) + const [busy, setBusy] = useState(false) + const [problem, setProblem] = useState(null) + // one creation at a time; a ref, because two clicks in one tick both see the + // same render's `busy` (see `useShareSurface`) + const busyRef = useRef(false) + const available = protectedShareAvailable() + + useDialogFocus(true, ref, () => { + if (!busyRef.current) onCancel() + }, returnFocusTo) + // The disclosure's contract (docs/localization.md Slice 2b): focus starts on + // Cancel, so Enter never creates a link by accident. `useDialogFocus` prefers + // the first field - here the checkbox - so this runs after it. + useEffect(() => { + cancelRef.current?.focus() + }, []) + // the fields appear when the box is ticked: put the caret in the first one + useEffect(() => { + if (protect) passwordRef.current?.focus() + }, [protect]) + + const submitProtected = async (e: SyntheticEvent) => { + e.preventDefault() + if (busyRef.current) return + const password = passwordRef.current?.value ?? '' + const again = confirmRef.current?.value ?? '' + const rule = passwordRule(password) + if (rule !== 'ok') { + setProblem({ kind: rule === 'too-short' ? 'short' : 'long' }) + passwordRef.current?.focus() + return + } + if (password.normalize('NFC') !== again.normalize('NFC')) { + setProblem({ kind: 'mismatch' }) + confirmRef.current?.focus() + return + } + busyRef.current = true + setBusy(true) + setProblem(null) + try { + const result = await onCreateProtected(password) + if (result.status === 'ok') return // the caller has closed the dialog; the fields go with it + if (result.status === 'too-large') setProblem({ kind: 'too-large', bytes: result.bytes, cap: result.cap }) + else if (result.status === 'password') setProblem({ kind: 'short' }) + else setProblem({ kind: result.status }) + } finally { + busyRef.current = false + setBusy(false) + } + } + + // Enter that only commits an IME composition must not submit the form + const guardComposition = (e: KeyboardEvent) => { + if (e.key === 'Enter' && (e.nativeEvent.isComposing || e.keyCode === 229)) e.preventDefault() + } + + const problemText = + problem == null + ? null + : problem.kind === 'too-large' + ? t('share.tooLarge', { size: shareKb(problem.bytes), cap: shareKb(problem.cap) }) + : t(PROBLEM_KEY[problem.kind]) + + return ( + +
e.stopPropagation()} + > +
+ {t('share.disclosure.title')} +
+
+

+ {shareDisclosureBody(t, temporary)} +

+ +

+ {available ? t('share.protect.optionHelp') : t('share.protect.unavailable')} +

+ {protect ? ( +
+ + + +

+ {t('share.protect.rule')} +

+
    +
  • {t('share.protect.note.channel')}
  • +
  • {t('share.protect.note.lost')}
  • +
  • {t('share.protect.note.strength')}
  • +
  • {t('share.protect.note.contract')}
  • +
+
+ ) : null} + {problemText ? ( + + ) : null} +

+ {busy ? t('share.protect.busy') : ''} +

+
+
+ + {protect ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/src/components/toolbar/ShareSurface.tsx b/src/components/toolbar/ShareSurface.tsx index 2baa3c85..4e026a62 100644 --- a/src/components/toolbar/ShareSurface.tsx +++ b/src/components/toolbar/ShareSurface.tsx @@ -2,8 +2,8 @@ import { useEffect, useRef } from 'react' import type { RefObject } from 'react' import { useT } from '../../i18n' import { selectTemporary, useSessionStore } from '../../store/sessionStore' -import { ConfirmDialog } from '../ConfirmDialog' -import { shareDisclosureBody } from './shareDisclosure' +import type { ProtectedShareLinkResult } from '../../ui/shareAction' +import { ShareCreateDialog } from './ShareCreateDialog' import { useAnchoredPosition } from './useAnchoredPosition' import { useOutsideDismiss } from './useOutsideDismiss' import type { ShareSurface as ShareSurfaceState } from './useShareSurface' @@ -16,6 +16,8 @@ type Props = { anchorRef: RefObject returnFocusTo: () => HTMLElement | null | undefined onConfirm: () => void + /** issue #300 — the protected link; anything but `ok` is shown inside the dialog */ + onConfirmProtected: (password: string) => Promise onCancel: () => void onRetryCopy: () => Promise onClosePanel: () => void @@ -32,6 +34,7 @@ export function ShareSurface({ anchorRef, returnFocusTo, onConfirm, + onConfirmProtected, onCancel, onRetryCopy, onClosePanel, @@ -66,12 +69,11 @@ export function ShareSurface({ return ( <> - @@ -87,6 +89,11 @@ export function ShareSurface({
{surface.copied ? t('share.panel.copied') : t('share.panel.copyThis')}
+ {surface.protected ? ( +
+ {t('share.panel.protected')} +
+ ) : null} => { + if (busyRef.current) return { status: 'unavailable' } + busyRef.current = true + setBusy(true) + try { + const result = await prepareProtectedShareLink(exportJSON(recommendedRunConfigForExport()), password) + if (result.status !== 'ok') return result + let copied = false + try { + await navigator.clipboard.writeText(result.url) + copied = true + } catch { + copied = false + } + setSurface({ phase: 'panel', url: result.url, copied, protected: true }) + return result + } finally { + busyRef.current = false + setBusy(false) + } + } + const retryCopy = async (): Promise => { if (surface?.phase !== 'panel') return false try { @@ -80,5 +107,5 @@ export function useShareSurface() { const closePanel = () => setSurface(null) - return { surface, busy, openConfirm, cancel, confirm, retryCopy, closePanel } + return { surface, busy, openConfirm, cancel, confirm, confirmProtected, retryCopy, closePanel } } diff --git a/src/i18n/locales/en/ui.ts b/src/i18n/locales/en/ui.ts index 2fe4ea94..21697fa0 100644 --- a/src/i18n/locales/en/ui.ts +++ b/src/i18n/locales/en/ui.ts @@ -462,6 +462,36 @@ const ui = { 'whatsNew.v0160.settings': 'Settings has a Storage and privacy area: the storage mode, switching sessions, deleting the stored diagram, and resetting everything the app keeps.', 'whatsNew.v0160.share': 'The share dialog says where a link can remain, and recommends a file for sensitive work.', 'whatsNew.v0160.portable': 'The portable file asks the question every time and recommends a temporary session.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'Protect with a password', + 'share.protect.optionHelp': 'The diagram is encrypted inside the link, and only someone who has the password can open it.', + 'share.protect.unavailable': 'Password protection needs a current browser and a secure (HTTPS) address, so it is not available here.', + 'share.protect.password': 'Password', + 'share.protect.confirm': 'Password again', + 'share.protect.show': 'Show password', + 'share.protect.hide': 'Hide password', + 'share.protect.rule': '12 to 128 characters. A long sentence or several words works well.', + 'share.protect.error.short': 'The password needs at least 12 characters.', + 'share.protect.error.long': 'The password can have at most 128 characters.', + 'share.protect.error.mismatch': 'The two passwords are not the same.', + 'share.protect.note.channel': 'Send the password some other way than the link, not in the same message.', + 'share.protect.note.lost': 'A lost password cannot be recovered. There is no server and no reset.', + 'share.protect.note.strength': 'The link is only as strong as its password: anyone who has the link can keep guessing, with no limit.', + 'share.protect.note.contract': 'Loop Studio does not store or transmit the password or the key. Your browser or a password manager may offer to save it.', + 'share.protect.busy': 'Encrypting…', + 'share.panel.protected': 'This link is protected with a password. Send the password separately.', + 'share.open.title': 'Open a protected diagram', + 'share.open.body': 'This link is protected with a password. Enter the password its sender gave you.', + 'share.open.submit': 'Open', + 'share.open.busy': 'Opening the diagram…', + 'share.open.error.auth': 'The password is wrong, or the link is damaged. Check the password and try again.', + 'share.open.note.personal': 'Once opened, the diagram is kept in this browser like any other diagram.', + 'share.open.note.temporary': 'In this temporary session the opened diagram is not kept in this browser.', + 'share.open.notice.title': 'Protected link', + 'share.open.notice.damaged': 'This protected link is damaged or incomplete and cannot be opened. Ask its sender for a new link.', + 'share.open.notice.unavailable': 'Opening a protected link needs a current browser and a secure (HTTPS) address. The link is still in the address bar, so it can be opened in another browser.', + 'share.open.notice.newer': 'This protected link was made with a newer version of Loop Studio. Update the app and open the link again: it is still in the address bar.', + 'share.open.notice.content': 'The password is correct, but the link does not contain a diagram that Loop Studio can open. Ask its sender for a new link.', } as const export type UiKey = keyof typeof ui diff --git a/src/i18n/locales/ko/ui.ts b/src/i18n/locales/ko/ui.ts index 856e5ec8..2b1513c3 100644 --- a/src/i18n/locales/ko/ui.ts +++ b/src/i18n/locales/ko/ui.ts @@ -465,6 +465,36 @@ const ui = { 'gate.temporary.rememberHelp': '이 시작 선택 하나만 저장됩니다.', 'storage.delete.workFailed': '저장된 다이어그램을 지우지 못했습니다. 이 브라우저에 아직 남아 있을 수 있습니다. 다시 시도하거나, 브라우저 설정에서 이 사이트의 데이터를 지우세요.', 'storage.delete.workKeepsTemporary': '지금 하고 있는 임시 작업은 그대로 열려 있습니다.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': '비밀번호로 보호', + 'share.protect.optionHelp': '다이어그램이 링크 안에 암호화되어 들어가며, 비밀번호를 가진 사람만 열 수 있습니다.', + 'share.protect.unavailable': '비밀번호 보호에는 최신 브라우저와 보안(HTTPS) 주소가 필요해서 여기서는 쓸 수 없습니다.', + 'share.protect.password': '비밀번호', + 'share.protect.confirm': '비밀번호 다시 입력', + 'share.protect.show': '비밀번호 표시', + 'share.protect.hide': '비밀번호 숨기기', + 'share.protect.rule': '12~128자. 긴 문장이나 여러 단어를 이어 쓰면 좋습니다.', + 'share.protect.error.short': '비밀번호는 12자 이상이어야 합니다.', + 'share.protect.error.long': '비밀번호는 128자 이하여야 합니다.', + 'share.protect.error.mismatch': '두 비밀번호가 서로 다릅니다.', + 'share.protect.note.channel': '비밀번호는 링크와 다른 경로로 보내세요. 같은 메시지에 함께 넣지 마세요.', + 'share.protect.note.lost': '비밀번호를 잊으면 되찾을 수 없습니다. 서버도, 재설정도 없습니다.', + 'share.protect.note.strength': '링크는 비밀번호만큼만 안전합니다. 링크를 가진 사람은 횟수 제한 없이 계속 추측해 볼 수 있습니다.', + 'share.protect.note.contract': 'Loop Studio는 비밀번호와 키를 저장하거나 전송하지 않습니다. 브라우저나 비밀번호 관리자가 저장을 제안할 수는 있습니다.', + 'share.protect.busy': '암호화하는 중…', + 'share.panel.protected': '이 링크는 비밀번호로 보호됩니다. 비밀번호는 따로 보내세요.', + 'share.open.title': '보호된 다이어그램 열기', + 'share.open.body': '이 링크는 비밀번호로 보호되어 있습니다. 보낸 사람에게 받은 비밀번호를 입력하세요.', + 'share.open.submit': '열기', + 'share.open.busy': '문서를 여는 중…', + 'share.open.error.auth': '비밀번호가 틀렸거나 링크가 손상되었습니다. 비밀번호를 확인하고 다시 시도하세요.', + 'share.open.note.personal': '열면 이 다이어그램은 다른 다이어그램과 마찬가지로 이 브라우저에 저장됩니다.', + 'share.open.note.temporary': '이 임시 세션에서는 연 다이어그램이 이 브라우저에 저장되지 않습니다.', + 'share.open.notice.title': '보호된 링크', + 'share.open.notice.damaged': '이 보호된 링크는 손상되었거나 불완전해서 열 수 없습니다. 보낸 사람에게 새 링크를 요청하세요.', + 'share.open.notice.unavailable': '보호된 링크를 열려면 최신 브라우저와 보안(HTTPS) 주소가 필요합니다. 링크는 주소 표시줄에 그대로 있으니 다른 브라우저에서 열 수 있습니다.', + 'share.open.notice.newer': '이 보호된 링크는 더 새로운 버전의 Loop Studio로 만들어졌습니다. 앱을 업데이트한 뒤 링크를 다시 여세요. 링크는 주소 표시줄에 그대로 있습니다.', + 'share.open.notice.content': '비밀번호는 맞지만 링크에 Loop Studio가 열 수 있는 다이어그램이 들어 있지 않습니다. 보낸 사람에게 새 링크를 요청하세요.', } satisfies Record export default ui diff --git a/src/index.css b/src/index.css index 9ce2f7f6..79870dbb 100644 --- a/src/index.css +++ b/src/index.css @@ -5700,6 +5700,17 @@ input[type='number']::-webkit-inner-spin-button { .sheet .share-pop__url { font-size: 12px; } + /* issue #300 - a password field under 16px makes iOS zoom the page on focus. + The base rules for these two sit at the END of this file, after this block, + so each override here carries one more class than its base rule. */ + .mcdlg--share .share-protect__field input { + font-size: 16px; + } + .mcdlg.mcdlg--confirm.mcdlg--share { + width: 100%; + max-width: 380px; + max-height: calc(100dvh - var(--sai-top) - var(--sai-bottom) - 16px); + } /* ---- "open a file" first-run hint (§MV6) — no account sync ---- */ .openhint { @@ -6813,3 +6824,103 @@ input[type='number']::-webkit-inner-spin-button { .mcdlg--storage:lang(ko) { word-break: keep-all; } + +/* ============================================================================ + Issue #300 - password protection for share links (SEMANTICS-P.md). + The share dialog with its optional password, and the prompt a protected link + opens with. Wider than the 320px confirm: two labelled fields and four short + notes. The body scrolls inside the dialog when the window is short. The + inputs keep the global `:focus-visible` outline, so the ring also shows in + forced colours. + ============================================================================ */ +.mcdlg--confirm.mcdlg--share { + width: 380px; + max-width: calc(100vw - 24px); + max-height: calc(100dvh - 24px); +} +.mcdlg--share .mcdlg__body { + overflow-y: auto; + overscroll-behavior: contain; + padding-bottom: 10px; +} +.share-protect { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; +} +/* the opening prompt's form wraps the body AND the foot: it must pass the + dialog's column layout through, or the body could not scroll */ +.share-protect--open { + gap: 0; + min-height: 0; +} +.share-protect__option { + display: flex; + align-items: flex-start; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + cursor: pointer; +} +.share-protect__option input { + margin: 2px 0 0; + flex: none; +} +.share-protect__help, +.share-protect__status { + margin: 0; + font-size: 11px; + line-height: 1.45; + color: var(--text-secondary); +} +.share-protect__status:empty { + display: none; +} +.share-protect__field { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 11px; + color: var(--text-secondary); +} +.share-protect__field input { + width: 100%; + padding: 6px 8px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); + background: var(--surface-raised); + border: 1px solid var(--line-control); + border-radius: var(--control-radius); +} +.share-protect__reveal { + align-self: flex-start; +} +.share-protect__notes { + margin: 0; + padding-inline-start: 16px; + display: flex; + flex-direction: column; + gap: 3px; + font-size: 11px; + line-height: 1.45; + color: var(--text-secondary); +} +/* a refusal: on the dialog it happened in, in the warning colour */ +.share-protect__problem { + margin: 0; + padding: 8px 10px; + border: 1px solid var(--warning); + border-radius: var(--control-radius); + font-size: 11.5px; + line-height: 1.45; + color: var(--text-primary); +} +.share-pop__status--protected { + font-weight: 400; +} +.mcdlg--share:lang(ko) { + word-break: keep-all; +} diff --git a/src/ui/shareAction.ts b/src/ui/shareAction.ts index 9e31bfd1..32ee313e 100644 --- a/src/ui/shareAction.ts +++ b/src/ui/shareAction.ts @@ -1,4 +1,5 @@ -import { SHARE_MAX_BYTES, encodeShareText } from '../model/share' +import { SHARE_MAX_BYTES, SHARE_PREFIX, encodeShareText } from '../model/share' +import { PROTECTED_PREFIX, ProtectedShareError, sealShareText } from '../model/shareProtected' // SEMANTICS-U.md §U7 — the shared parts of "make a share link", used by the // desktop `ShareButton` and the mobile More menu. The one-time disclosure (an @@ -20,9 +21,9 @@ export function shareCap(): number { * http(s) URL (a build misconfiguration — the caller surfaces an error, never a * silent `null/...` link). */ -export function buildShareUrl(payload: string): string | null { +export function buildShareUrl(payload: string, prefix: string = SHARE_PREFIX): string | null { try { - const u = new URL(`#g1=${payload}`, __SHARE_BASE_URL__) + const u = new URL(`#${prefix}${payload}`, __SHARE_BASE_URL__) return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : null } catch { return null @@ -42,3 +43,26 @@ export async function prepareShareLink(doc: string): Promise { const url = buildShareUrl(payload) return url == null ? { status: 'no-base' } : { status: 'ok', url } } + +export type ProtectedShareLinkResult = + | ShareLinkResult + | { status: 'unavailable' } // no Web Crypto on this page + | { status: 'password' } // the 12..128 rule; the dialog checks it first, this is the second line + +/** + * SEMANTICS-P.md SS P5 - seal `doc` with `password`, enforce the SAME cap on the + * protected payload (a hard reject, never a truncation), build the `#p1=` URL. + * No side effects; the password is passed straight through and not kept. + */ +export async function prepareProtectedShareLink(doc: string, password: string): Promise { + let sealed: { payload: string; bytes: number } + try { + sealed = await sealShareText(doc, password) + } catch (e) { + return { status: e instanceof ProtectedShareError && e.reason === 'password' ? 'password' : 'unavailable' } + } + const cap = shareCap() + if (sealed.bytes > cap) return { status: 'too-large', bytes: sealed.bytes, cap } + const url = buildShareUrl(sealed.payload, PROTECTED_PREFIX) + return url == null ? { status: 'no-base' } : { status: 'ok', url } +} From f3b9e1c7c4bcded3a3b02d7e4c9e5dba83a87d26 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:09:07 +0900 Subject: [PATCH 4/8] feat(share): sixteen more languages, the 0.17.0 release note and the guards that hold them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #300. The fourth of five staged commits; with it the branch type-checks again and every check passes. The 29 dialog strings are now in all eighteen languages, and four release-note lines (`whatsNew.v0170.*`) say what changed: a share link can be protected with a password, a protected link asks for its password before anything is shown and gives one message for a wrong password and a damaged link, a lost password cannot be recovered and Loop Studio neither stores nor transmits it, and the plain link is still the default. The sixteen translations other than English and Korean were written here and have had no native review. Version 0.17.0, release note `release:0.17.0`, change declaration `.changes/share-link-password.json`. The date is the day of the merge and is set again right before it. Guards re-pinned for 33 new keys (944 to 977, runtime surfaces 1,166 to 1,199): `HTTPS` joins the declared Latin and kept-English vocabularies (es-419, it, nl, th, vi) and `password` the Italian one; eight keys join the es-ES audit, where Spain says `usted` and `gestor de contraseñas`; seven words join the Russian list of spellings with the letter yo; the Thai and Vietnamese lists gain the six strings that name Loop Studio or HTTPS. The pt-PT delta goes from 227 to 255, and its bound on how much of the catalog may differ from pt-BR moves from a quarter to 30 percent: the word for a password itself differs (`palavra-passe`, `senha`), so 28 of the 33 strings differ for one reason. The exact count stays pinned. Two checks learn about the new dialogs. `check:form-direction` now judges an input whose type is a conditional between two literals (the show or hide control) by both branches, and the three password fields declare `dir="auto"`. The ICU argument manifest declares the two size arguments the create dialog passes to `share.tooLarge`. --- .changes/share-link-password.json | 1 + package-lock.json | 4 +-- package.json | 2 +- scripts/check-form-direction.mjs | 13 ++++++-- scripts/icu-argument-disposition.json | 26 +++++++++++++-- src/components/ProtectedLinkDialog.tsx | 1 + src/components/toolbar/ShareCreateDialog.tsx | 2 ++ src/i18n/es419Copy.test.ts | 4 ++- src/i18n/esEsCopy.test.ts | 11 ++++++ src/i18n/itCopy.test.ts | 16 +++++---- src/i18n/locales/ar/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/de/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/en/ui.ts | 5 +++ src/i18n/locales/es-419/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/es-ES/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/fr/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/it/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/ja/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/ko/ui.ts | 5 +++ src/i18n/locales/nl/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/pt-BR/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/pt-PT/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/ru/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/th/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/tr/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/vi/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/zh-Hans/ui.ts | 35 ++++++++++++++++++++ src/i18n/locales/zh-Hant/ui.ts | 35 ++++++++++++++++++++ src/i18n/nlCopy.test.ts | 8 +++-- src/i18n/ptPtCopy.test.ts | 16 ++++++--- src/i18n/ruCopy.test.ts | 11 +++++- src/i18n/thCopy.test.ts | 18 +++++++--- src/i18n/trCopy.test.ts | 2 +- src/i18n/viCopy.test.ts | 18 +++++++--- src/releaseNotes/releaseNotes.ts | 7 ++++ 35 files changed, 696 insertions(+), 34 deletions(-) create mode 100644 .changes/share-link-password.json diff --git a/.changes/share-link-password.json b/.changes/share-link-password.json new file mode 100644 index 00000000..52708fff --- /dev/null +++ b/.changes/share-link-password.json @@ -0,0 +1 @@ +{ "type": "user-facing", "releaseNoteId": "release:0.17.0" } diff --git a/package-lock.json b/package-lock.json index 421b225b..d6cca7c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loop-studio", - "version": "0.16.0", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loop-studio", - "version": "0.16.0", + "version": "0.17.0", "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", diff --git a/package.json b/package.json index abe64c76..9a6675a0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loop-studio", "private": true, - "version": "0.16.0", + "version": "0.17.0", "type": "module", "engines": { "node": ">=22.12.0" diff --git a/scripts/check-form-direction.mjs b/scripts/check-form-direction.mjs index 0bf22166..79b0b2a8 100644 --- a/scripts/check-form-direction.mjs +++ b/scripts/check-form-direction.mjs @@ -74,14 +74,23 @@ for (const abs of files) { if (!a) return null if (!a.initializer) return 'true' if (ts.isStringLiteral(a.initializer)) return a.initializer.text + // `cond ? 'text' : 'password'` (a show / hide control, issue #300): both + // branches are literals, so both can be judged. Anything else stays + // undecidable and fails below. + const e = ts.isJsxExpression(a.initializer) ? a.initializer.expression : null + if (e && ts.isConditionalExpression(e) && ts.isStringLiteral(e.whenTrue) && ts.isStringLiteral(e.whenFalse)) { + return e.whenTrue.text + '|' + e.whenFalse.text + } return '(expression)' } const type = attr('type') const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1 const spread = open.attributes.properties.some((x) => ts.isJsxSpreadAttribute(x)) + // a conditional type carries text only when EVERY branch does + const types = type === null ? [] : type.split('|') const textual = - tag === 'textarea' || type === null || TEXTUAL_INPUT_TYPES.has(type ?? '') - if (!textual && !NON_TEXTUAL.has(type ?? '')) { + tag === 'textarea' || type === null || types.every((x) => TEXTUAL_INPUT_TYPES.has(x)) + if (!textual && !types.every((x) => NON_TEXTUAL.has(x))) { problems.push(`${rel}:${line} <${tag} type="${type}"> - unknown input type, cannot decide whether it carries text`) } else if (textual) { const dir = attr('dir') diff --git a/scripts/icu-argument-disposition.json b/scripts/icu-argument-disposition.json index 2ed2f146..fd46eb05 100644 --- a/scripts/icu-argument-disposition.json +++ b/scripts/icu-argument-disposition.json @@ -11,11 +11,11 @@ }, "$implemented": "A row classed `auto` / `ltr` / `per-fragment` is an OPEN obligation: the evidence says the value needs bounding and the code does not bound it yet. C3.5 was scoped to fix the two families that were already evidenced — the unrecognized-column header and the five `import.loc.*` calls — and those moved to `isolate-obligations.json`. The checker asserts these rows are NOT wrapped, so implementing one without moving its row is red in both directions.", "totals": { - "arguments": 210, - "rows": 51, + "arguments": 212, + "rows": 53, "byClass": { "attribute": 10, - "closed-value": 41 + "closed-value": 43 }, "recordedNotImplemented": 0, "attributeSink": 18 @@ -451,6 +451,26 @@ "class": "closed-value", "evidence": "`simStore.initError` — an engine initialisation phrase, Latin-leading." }, + { + "file": "src/components/toolbar/ShareCreateDialog.tsx", + "enclosing": "ShareCreateBody", + "key": "share.tooLarge", + "arg": "cap", + "ord": 1, + "sink": "indirect", + "class": "closed-value", + "evidence": "KB, formatted by `shareKb()`." + }, + { + "file": "src/components/toolbar/ShareCreateDialog.tsx", + "enclosing": "ShareCreateBody", + "key": "share.tooLarge", + "arg": "size", + "ord": 1, + "sink": "indirect", + "class": "closed-value", + "evidence": "KB, formatted by `shareKb()`." + }, { "file": "src/components/toolbar/useShareSurface.ts", "enclosing": "useShareSurface>confirm", diff --git a/src/components/ProtectedLinkDialog.tsx b/src/components/ProtectedLinkDialog.tsx index 987f2719..ff1416bf 100644 --- a/src/components/ProtectedLinkDialog.tsx +++ b/src/components/ProtectedLinkDialog.tsx @@ -97,6 +97,7 @@ function Prompt({ busy, failures }: { busy: boolean; failures: number }) { { 'no No normal panel Roles series Total use Use web zoom ' + // issue #297: a browser name, a file format, and two Spanish/English // homographs (`accidental`, `personal`) in the storage gate and area - 'Chrome HTML accidental personal' + 'Chrome HTML accidental personal ' + + // issue #300: the protocol the password dialogs name + 'HTTPS' ).split(' '), ) // allowed ONLY on the surface that owns it — the key set is the contract, diff --git a/src/i18n/esEsCopy.test.ts b/src/i18n/esEsCopy.test.ts index 181909b1..d43c6756 100644 --- a/src/i18n/esEsCopy.test.ts +++ b/src/i18n/esEsCopy.test.ts @@ -117,6 +117,17 @@ describe('es-ES — the region audit, as a contract', () => { 'storage.delete.workKeepsTemporary', 'storage.mode.temporary', 'whatsNew.v0160.temporary', + // issue #300 - the password dialogs: `usted` where es-419 says `tú` + // (`Envíe`, `Introduzca`, `Compruebe`, `Pida`, `Actualice`), and + // `gestor de contraseñas` where es-419 says `administrador` + 'share.open.body', + 'share.open.error.auth', + 'share.open.notice.content', + 'share.open.notice.damaged', + 'share.open.notice.newer', + 'share.panel.protected', + 'share.protect.note.channel', + 'share.protect.note.contract', // 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 9e0ace83..e367b0ea 100644 --- a/src/i18n/itCopy.test.ts +++ b/src/i18n/itCopy.test.ts @@ -144,6 +144,9 @@ const SURVIVING: ReadonlyArray = [ // and `privacy` are the words Italian uses, `area` is Italian, Chrome and // HTML are names ['the storage gate (issue #297)', ['computer', 'privacy', 'area', 'chrome', 'html']], + // issue #300 - password protection for share links: `password` is the word + // Italian uses, HTTPS is a protocol name + ['password protection for share links (issue #300)', ['password', 'https']], ] const DECLARED = new Set(SURVIVING.flatMap(([, ws]) => ws)) @@ -165,6 +168,7 @@ const IDENTICAL_TO_EN: ReadonlyArray = [ ['catalog revChip.rev', 'rev {id}'], ['catalog mc.title', 'Monte Carlo'], ['catalog review.field.base', 'base'], + ['catalog share.protect.password', 'Password'], // issue #300: the Italian word ['catalog dist.seed', 'seed'], ['catalog tour.nav.position', '{n} / {total}'], ['catalog help.contextual.hint.mc.name', 'Monte Carlo'], @@ -195,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(944) + expect(KEYS).toHaveLength(977) 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: 944, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1166) + expect(per).toEqual({ catalog: 977, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1199) }) it('EVERY row has a non-empty ENGLISH side — the vacuity guard', () => { @@ -284,9 +288,9 @@ describe('it copy — what is identical to English, exactly', () => { expect([...actual].sort()).toEqual([...IDENTICAL_TO_EN].sort()) }) - it('the declared set is what it claims — 38 pairs, no duplicate key', () => { - expect(IDENTICAL_TO_EN).toHaveLength(38) - expect(new Set(IDENTICAL_TO_EN.map(([k]) => k)).size).toBe(38) + it('the declared set is what it claims — 39 pairs, no duplicate key', () => { + expect(IDENTICAL_TO_EN).toHaveLength(39) + expect(new Set(IDENTICAL_TO_EN.map(([k]) => k)).size).toBe(39) }) }) diff --git a/src/i18n/locales/ar/ui.ts b/src/i18n/locales/ar/ui.ts index 23462344..96d30dc3 100644 --- a/src/i18n/locales/ar/ui.ts +++ b/src/i18n/locales/ar/ui.ts @@ -518,6 +518,41 @@ const ui = { 'gate.temporary.rememberHelp': 'لا يُحفظ سوى خيار البدء هذا.', 'storage.delete.workFailed': 'تعذّرت إزالة المخطط المحفوظ، وقد يكون لا يزال في هذا المتصفح. حاول مرة أخرى، أو امسح بيانات هذا الموقع من إعدادات المتصفح نفسه.', 'storage.delete.workKeepsTemporary': 'يبقى عملك المؤقت الحالي مفتوحًا.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'الحماية بكلمة مرور', + 'share.protect.optionHelp': 'يُشفَّر المخطط داخل الرابط، ولا يستطيع فتحه إلا من يملك كلمة المرور.', + 'share.protect.unavailable': 'تتطلب الحماية بكلمة مرور متصفحًا حديثًا وعنوانًا آمنًا (HTTPS)، ولذلك فهي غير متاحة هنا.', + 'share.protect.password': 'كلمة المرور', + 'share.protect.confirm': 'كلمة المرور مرة أخرى', + 'share.protect.show': 'إظهار كلمة المرور', + 'share.protect.hide': 'إخفاء كلمة المرور', + 'share.protect.rule': 'من 12 إلى 128 حرفًا. جملة طويلة أو عدة كلمات خيار جيد.', + 'share.protect.error.short': 'يجب ألا تقل كلمة المرور عن 12 حرفًا.', + 'share.protect.error.long': 'يجب ألا تزيد كلمة المرور على 128 حرفًا.', + 'share.protect.error.mismatch': 'كلمتا المرور غير متطابقتين.', + 'share.protect.note.channel': 'أرسل كلمة المرور بطريقة أخرى غير طريقة الرابط، وليس في الرسالة نفسها.', + 'share.protect.note.lost': 'لا يمكن استرجاع كلمة مرور مفقودة. لا يوجد خادم ولا إعادة تعيين.', + 'share.protect.note.strength': 'قوة الرابط من قوة كلمة المرور: من يملك الرابط يستطيع مواصلة التخمين بلا حد.', + 'share.protect.note.contract': 'لا يخزّن Loop Studio كلمة المرور ولا المفتاح ولا يرسلهما. قد يعرض متصفحك أو مدير كلمات المرور حفظها.', + 'share.protect.busy': 'جارٍ التشفير…', + 'share.panel.protected': 'هذا الرابط محمي بكلمة مرور. أرسل كلمة المرور على حدة.', + 'share.open.title': 'فتح مخطط محمي', + 'share.open.body': 'هذا الرابط محمي بكلمة مرور. أدخل كلمة المرور التي أعطاك إياها المرسل.', + 'share.open.submit': 'فتح', + 'share.open.busy': 'جارٍ فتح المخطط…', + 'share.open.error.auth': 'كلمة المرور خاطئة، أو الرابط تالف. تحقق من كلمة المرور وحاول مرة أخرى.', + 'share.open.note.personal': 'بعد فتحه، يُحتفظ بالمخطط في هذا المتصفح مثل أي مخطط آخر.', + 'share.open.note.temporary': 'في هذه الجلسة المؤقتة لا يُحتفظ بالمخطط المفتوح في هذا المتصفح.', + 'share.open.notice.title': 'رابط محمي', + 'share.open.notice.damaged': 'هذا الرابط المحمي تالف أو غير مكتمل ولا يمكن فتحه. اطلب رابطًا جديدًا من المرسل.', + 'share.open.notice.unavailable': 'يتطلب فتح رابط محمي متصفحًا حديثًا وعنوانًا آمنًا (HTTPS). الرابط ما زال في شريط العنوان، ويمكن فتحه في متصفح آخر.', + 'share.open.notice.newer': 'أُنشئ هذا الرابط المحمي بإصدار أحدث من Loop Studio. حدّث التطبيق وافتح الرابط من جديد: ما زال في شريط العنوان.', + 'share.open.notice.content': 'كلمة المرور صحيحة، لكن الرابط لا يحتوي على مخطط يستطيع Loop Studio فتحه. اطلب رابطًا جديدًا من المرسل.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'يمكن حماية رابط المشاركة بكلمة مرور: يُشفَّر المخطط داخل الرابط، ولا يستطيع فتحه إلا من يملك كلمة المرور.', + 'whatsNew.v0170.open': 'يطلب الرابط المحمي كلمة المرور قبل عرض أي شيء من المخطط المشترك. كلمة المرور الخاطئة والرابط التالف يعطيان الرسالة نفسها.', + 'whatsNew.v0170.limits': 'لا يمكن استرجاع كلمة مرور مفقودة، وقوة الرابط من قوة كلمة المرور. لا يخزّن Loop Studio كلمة المرور ولا المفتاح ولا يرسلهما.', + 'whatsNew.v0170.plain': 'الرابط العادي ما زال الخيار الافتراضي ويعمل كما كان.', } as const export default ui diff --git a/src/i18n/locales/de/ui.ts b/src/i18n/locales/de/ui.ts index 847ddeb6..fd2ce9ab 100644 --- a/src/i18n/locales/de/ui.ts +++ b/src/i18n/locales/de/ui.ts @@ -553,6 +553,41 @@ const ui = { 'gate.temporary.rememberHelp': 'Nur diese Startauswahl wird gespeichert.', 'storage.delete.workFailed': 'Das gespeicherte Diagramm konnte nicht entfernt werden und ist möglicherweise noch in diesem Browser. Versuche es erneut, oder lösche die Daten dieser Website in den Einstellungen des Browsers.', 'storage.delete.workKeepsTemporary': 'Deine aktuelle temporäre Arbeit bleibt geöffnet.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'Mit einem Passwort schützen', + 'share.protect.optionHelp': 'Das Diagramm wird im Link verschlüsselt, und nur wer das Passwort hat, kann es öffnen.', + 'share.protect.unavailable': 'Der Passwortschutz braucht einen aktuellen Browser und eine sichere (HTTPS-)Adresse und ist hier deshalb nicht verfügbar.', + 'share.protect.password': 'Passwort', + 'share.protect.confirm': 'Passwort wiederholen', + 'share.protect.show': 'Passwort anzeigen', + 'share.protect.hide': 'Passwort verbergen', + 'share.protect.rule': '12 bis 128 Zeichen. Ein langer Satz oder mehrere Wörter eignen sich gut.', + 'share.protect.error.short': 'Das Passwort braucht mindestens 12 Zeichen.', + 'share.protect.error.long': 'Das Passwort darf höchstens 128 Zeichen haben.', + 'share.protect.error.mismatch': 'Die beiden Passwörter stimmen nicht überein.', + 'share.protect.note.channel': 'Schicke das Passwort auf einem anderen Weg als den Link, nicht in derselben Nachricht.', + 'share.protect.note.lost': 'Ein verlorenes Passwort lässt sich nicht wiederherstellen. Es gibt keinen Server und kein Zurücksetzen.', + 'share.protect.note.strength': 'Der Link ist nur so stark wie sein Passwort: Wer den Link hat, kann unbegrenzt weiterraten.', + 'share.protect.note.contract': 'Loop Studio speichert und überträgt weder das Passwort noch den Schlüssel. Dein Browser oder ein Passwortmanager kann anbieten, es zu speichern.', + 'share.protect.busy': 'Wird verschlüsselt …', + 'share.panel.protected': 'Dieser Link ist mit einem Passwort geschützt. Schicke das Passwort getrennt.', + 'share.open.title': 'Ein geschütztes Diagramm öffnen', + 'share.open.body': 'Dieser Link ist mit einem Passwort geschützt. Gib das Passwort ein, das du vom Absender bekommen hast.', + 'share.open.submit': 'Öffnen', + 'share.open.busy': 'Diagramm wird geöffnet …', + 'share.open.error.auth': 'Das Passwort ist falsch, oder der Link ist beschädigt. Prüfe das Passwort und versuche es erneut.', + 'share.open.note.personal': 'Nach dem Öffnen wird das Diagramm wie jedes andere in diesem Browser behalten.', + 'share.open.note.temporary': 'In dieser temporären Sitzung wird das geöffnete Diagramm nicht in diesem Browser behalten.', + 'share.open.notice.title': 'Geschützter Link', + 'share.open.notice.damaged': 'Dieser geschützte Link ist beschädigt oder unvollständig und lässt sich nicht öffnen. Bitte den Absender um einen neuen Link.', + 'share.open.notice.unavailable': 'Um einen geschützten Link zu öffnen, braucht es einen aktuellen Browser und eine sichere (HTTPS-)Adresse. Der Link steht weiter in der Adressleiste und lässt sich in einem anderen Browser öffnen.', + 'share.open.notice.newer': 'Dieser geschützte Link wurde mit einer neueren Version von Loop Studio erstellt. Aktualisiere die App und öffne den Link erneut: Er steht weiter in der Adressleiste.', + 'share.open.notice.content': 'Das Passwort stimmt, aber der Link enthält kein Diagramm, das Loop Studio öffnen kann. Bitte den Absender um einen neuen Link.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'Ein Link zum Teilen lässt sich mit einem Passwort schützen: Das Diagramm wird im Link verschlüsselt, und nur wer das Passwort hat, kann es öffnen.', + 'whatsNew.v0170.open': 'Ein geschützter Link fragt nach seinem Passwort, bevor irgendetwas vom geteilten Diagramm gezeigt wird. Ein falsches Passwort und ein beschädigter Link 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.', } satisfies Record export default ui diff --git a/src/i18n/locales/en/ui.ts b/src/i18n/locales/en/ui.ts index 21697fa0..a3c0f448 100644 --- a/src/i18n/locales/en/ui.ts +++ b/src/i18n/locales/en/ui.ts @@ -492,6 +492,11 @@ const ui = { 'share.open.notice.unavailable': 'Opening a protected link needs a current browser and a secure (HTTPS) address. The link is still in the address bar, so it can be opened in another browser.', 'share.open.notice.newer': 'This protected link was made with a newer version of Loop Studio. Update the app and open the link again: it is still in the address bar.', 'share.open.notice.content': 'The password is correct, but the link does not contain a diagram that Loop Studio can open. Ask its sender for a new link.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'A share link can be protected with a password: the diagram is encrypted inside the link, and only someone who has the password can open it.', + 'whatsNew.v0170.open': 'A protected link asks for its password before anything from the shared diagram is shown. A wrong password and a damaged link 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.', } 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 378ce70e..f49e481f 100644 --- a/src/i18n/locales/es-419/ui.ts +++ b/src/i18n/locales/es-419/ui.ts @@ -476,6 +476,41 @@ const ui = { 'gate.temporary.rememberHelp': 'Solo se guarda esta elección de inicio.', 'storage.delete.workFailed': 'No se pudo eliminar el diagrama guardado y puede que siga en este navegador. Inténtalo de nuevo o borra los datos de este sitio en la configuración del propio navegador.', 'storage.delete.workKeepsTemporary': 'Tu trabajo temporal actual sigue abierto.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'Proteger con una contraseña', + 'share.protect.optionHelp': 'El diagrama se cifra dentro del enlace, y solo quien tenga la contraseña puede abrirlo.', + 'share.protect.unavailable': 'La protección con contraseña necesita un navegador actual y una dirección segura (HTTPS), por lo que aquí no está disponible.', + 'share.protect.password': 'Contraseña', + 'share.protect.confirm': 'Contraseña, otra vez', + 'share.protect.show': 'Mostrar la contraseña', + 'share.protect.hide': 'Ocultar la contraseña', + 'share.protect.rule': 'De 12 a 128 caracteres. Una frase larga o varias palabras funcionan bien.', + 'share.protect.error.short': 'La contraseña necesita al menos 12 caracteres.', + 'share.protect.error.long': 'La contraseña puede tener como máximo 128 caracteres.', + 'share.protect.error.mismatch': 'Las dos contraseñas no son iguales.', + 'share.protect.note.channel': 'Envía la contraseña por otra vía distinta del enlace, no en el mismo mensaje.', + 'share.protect.note.lost': 'Una contraseña perdida no se puede recuperar. No hay servidor ni restablecimiento.', + 'share.protect.note.strength': 'El enlace es tan fuerte como su contraseña: quien tenga el enlace puede seguir probando, sin límite.', + 'share.protect.note.contract': 'Loop Studio no almacena ni transmite la contraseña ni la clave. El navegador o un administrador de contraseñas puede ofrecer guardarla.', + 'share.protect.busy': 'Cifrando…', + 'share.panel.protected': 'Este enlace está protegido con una contraseña. Envía la contraseña por separado.', + 'share.open.title': 'Abrir un diagrama protegido', + 'share.open.body': 'Este enlace está protegido con una contraseña. Ingresa la contraseña que te dio quien lo envió.', + 'share.open.submit': 'Abrir', + 'share.open.busy': 'Abriendo el diagrama…', + 'share.open.error.auth': 'La contraseña es incorrecta o el enlace está dañado. Revisa la contraseña e inténtalo de nuevo.', + 'share.open.note.personal': 'Una vez abierto, el diagrama se conserva en este navegador como cualquier otro diagrama.', + 'share.open.note.temporary': 'En esta sesión temporal el diagrama abierto no se conserva en este navegador.', + 'share.open.notice.title': 'Enlace protegido', + 'share.open.notice.damaged': 'Este enlace protegido está dañado o incompleto y no se puede abrir. Pide un enlace nuevo a quien lo envió.', + 'share.open.notice.unavailable': 'Abrir un enlace protegido necesita un navegador actual y una dirección segura (HTTPS). El enlace sigue en la barra de direcciones, así que puede abrirse en otro navegador.', + 'share.open.notice.newer': 'Este enlace protegido se creó con una versión más reciente de Loop Studio. Actualiza la aplicación y vuelve a abrir el enlace: sigue en la barra de direcciones.', + 'share.open.notice.content': 'La contraseña es correcta, pero el enlace no contiene un diagrama que Loop Studio pueda abrir. Pide un enlace nuevo a quien lo envió.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'Un enlace para compartir puede protegerse con una contraseña: el diagrama se cifra dentro del enlace, y solo quien tenga la contraseña puede abrirlo.', + 'whatsNew.v0170.open': 'Un enlace protegido pide su contraseña antes de mostrar nada del diagrama compartido. Una contraseña incorrecta y un enlace dañado 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.', } 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 d2e0ebd6..e6a4b750 100644 --- a/src/i18n/locales/es-ES/ui.ts +++ b/src/i18n/locales/es-ES/ui.ts @@ -490,6 +490,41 @@ const ui = { 'gate.temporary.rememberHelp': 'Solo se guarda esta elección de inicio.', 'storage.delete.workFailed': 'No se ha podido eliminar el diagrama guardado y puede que siga en este navegador. Inténtelo de nuevo o borre los datos de este sitio en los ajustes del propio navegador.', 'storage.delete.workKeepsTemporary': 'Su trabajo temporal actual sigue abierto.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'Proteger con una contraseña', + 'share.protect.optionHelp': 'El diagrama se cifra dentro del enlace, y solo quien tenga la contraseña puede abrirlo.', + 'share.protect.unavailable': 'La protección con contraseña necesita un navegador actual y una dirección segura (HTTPS), por lo que aquí no está disponible.', + 'share.protect.password': 'Contraseña', + 'share.protect.confirm': 'Contraseña, otra vez', + 'share.protect.show': 'Mostrar la contraseña', + 'share.protect.hide': 'Ocultar la contraseña', + 'share.protect.rule': 'De 12 a 128 caracteres. Una frase larga o varias palabras funcionan bien.', + 'share.protect.error.short': 'La contraseña necesita al menos 12 caracteres.', + 'share.protect.error.long': 'La contraseña puede tener como máximo 128 caracteres.', + 'share.protect.error.mismatch': 'Las dos contraseñas no son iguales.', + 'share.protect.note.channel': 'Envíe la contraseña por otra vía distinta del enlace, no en el mismo mensaje.', + 'share.protect.note.lost': 'Una contraseña perdida no se puede recuperar. No hay servidor ni restablecimiento.', + 'share.protect.note.strength': 'El enlace es tan fuerte como su contraseña: quien tenga el enlace puede seguir probando, sin límite.', + 'share.protect.note.contract': 'Loop Studio no almacena ni transmite la contraseña ni la clave. El navegador o un gestor de contraseñas puede ofrecerse a guardarla.', + 'share.protect.busy': 'Cifrando…', + 'share.panel.protected': 'Este enlace está protegido con una contraseña. Envíe la contraseña por separado.', + 'share.open.title': 'Abrir un diagrama protegido', + 'share.open.body': 'Este enlace está protegido con una contraseña. Introduzca la contraseña que le dio quien lo envió.', + 'share.open.submit': 'Abrir', + 'share.open.busy': 'Abriendo el diagrama…', + 'share.open.error.auth': 'La contraseña es incorrecta o el enlace está dañado. Compruebe la contraseña e inténtelo de nuevo.', + 'share.open.note.personal': 'Una vez abierto, el diagrama se conserva en este navegador como cualquier otro diagrama.', + 'share.open.note.temporary': 'En esta sesión temporal el diagrama abierto no se conserva en este navegador.', + 'share.open.notice.title': 'Enlace protegido', + 'share.open.notice.damaged': 'Este enlace protegido está dañado o incompleto y no se puede abrir. Pida un enlace nuevo a quien lo envió.', + 'share.open.notice.unavailable': 'Abrir un enlace protegido necesita un navegador actual y una dirección segura (HTTPS). El enlace sigue en la barra de direcciones, así que puede abrirse en otro navegador.', + 'share.open.notice.newer': 'Este enlace protegido se creó con una versión más reciente de Loop Studio. Actualice la aplicación y vuelva a abrir el enlace: sigue en la barra de direcciones.', + 'share.open.notice.content': 'La contraseña es correcta, pero el enlace no contiene un diagrama que Loop Studio pueda abrir. Pida un enlace nuevo a quien lo envió.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'Un enlace para compartir puede protegerse con una contraseña: el diagrama se cifra dentro del enlace, y solo quien tenga la contraseña puede abrirlo.', + 'whatsNew.v0170.open': 'Un enlace protegido pide su contraseña antes de mostrar nada del diagrama compartido. Una contraseña incorrecta y un enlace dañado 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.', } 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 4eb81758..6f428934 100644 --- a/src/i18n/locales/fr/ui.ts +++ b/src/i18n/locales/fr/ui.ts @@ -547,6 +547,41 @@ const ui = { 'gate.temporary.rememberHelp': 'Seul ce choix de démarrage est enregistré.', 'storage.delete.workFailed': 'Le diagramme enregistré n’a pas pu être supprimé et se trouve peut-être encore dans ce navigateur. Réessayez, ou effacez les données de ce site dans les paramètres du navigateur lui-même.', 'storage.delete.workKeepsTemporary': 'Votre travail temporaire actuel reste ouvert.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'Protéger par un mot de passe', + 'share.protect.optionHelp': 'Le diagramme est chiffré dans le lien, et seule une personne qui a le mot de passe peut l’ouvrir.', + 'share.protect.unavailable': 'La protection par mot de passe demande un navigateur récent et une adresse sécurisée (HTTPS) : elle n’est donc pas disponible ici.', + 'share.protect.password': 'Mot de passe', + 'share.protect.confirm': 'Mot de passe, à nouveau', + 'share.protect.show': 'Afficher le mot de passe', + 'share.protect.hide': 'Masquer le mot de passe', + 'share.protect.rule': 'De 12 à 128 caractères. Une longue phrase ou plusieurs mots conviennent bien.', + 'share.protect.error.short': 'Le mot de passe doit compter au moins 12 caractères.', + 'share.protect.error.long': 'Le mot de passe peut compter au plus 128 caractères.', + 'share.protect.error.mismatch': 'Les deux mots de passe ne sont pas identiques.', + 'share.protect.note.channel': 'Envoyez le mot de passe par un autre moyen que le lien, pas dans le même message.', + 'share.protect.note.lost': 'Un mot de passe perdu ne peut pas être récupéré. Il n’y a ni serveur ni réinitialisation.', + 'share.protect.note.strength': 'Le lien n’est pas plus solide que son mot de passe : quiconque a le lien peut continuer à deviner, sans limite.', + 'share.protect.note.contract': 'Loop Studio ne conserve ni ne transmet le mot de passe ou la clé. Votre navigateur ou un gestionnaire de mots de passe peut proposer de l’enregistrer.', + 'share.protect.busy': 'Chiffrement…', + 'share.panel.protected': 'Ce lien est protégé par un mot de passe. Envoyez le mot de passe séparément.', + 'share.open.title': 'Ouvrir un diagramme protégé', + 'share.open.body': 'Ce lien est protégé par un mot de passe. Saisissez le mot de passe que son expéditeur vous a donné.', + 'share.open.submit': 'Ouvrir', + 'share.open.busy': 'Ouverture du diagramme…', + 'share.open.error.auth': 'Le mot de passe est incorrect, ou le lien est endommagé. Vérifiez le mot de passe et réessayez.', + 'share.open.note.personal': 'Une fois ouvert, le diagramme est conservé dans ce navigateur comme tout autre diagramme.', + 'share.open.note.temporary': 'Dans cette session temporaire, le diagramme ouvert n’est pas conservé dans ce navigateur.', + 'share.open.notice.title': 'Lien protégé', + 'share.open.notice.damaged': 'Ce lien protégé est endommagé ou incomplet et ne peut pas être ouvert. Demandez un nouveau lien à son expéditeur.', + 'share.open.notice.unavailable': 'Ouvrir un lien protégé demande un navigateur récent et une adresse sécurisée (HTTPS). Le lien est toujours dans la barre d’adresse : il peut être ouvert dans un autre navigateur.', + 'share.open.notice.newer': 'Ce lien protégé a été créé avec une version plus récente de Loop Studio. Mettez l’application à jour et ouvrez de nouveau le lien : il est toujours dans la barre d’adresse.', + 'share.open.notice.content': 'Le mot de passe est correct, mais le lien ne contient pas de diagramme que Loop Studio puisse ouvrir. Demandez un nouveau lien à son expéditeur.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'Un lien de partage peut être protégé par un mot de passe : le diagramme est chiffré dans le lien, et seule une personne qui a le mot de passe peut l’ouvrir.', + '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 un lien endommagé 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.', } satisfies Record export default ui diff --git a/src/i18n/locales/it/ui.ts b/src/i18n/locales/it/ui.ts index 1933359e..624de4a5 100644 --- a/src/i18n/locales/it/ui.ts +++ b/src/i18n/locales/it/ui.ts @@ -492,6 +492,41 @@ const ui = { 'gate.temporary.rememberHelp': 'Viene salvata solo questa scelta di avvio.', 'storage.delete.workFailed': 'Non è stato possibile rimuovere il diagramma salvato, che potrebbe trovarsi ancora in questo browser. Riprova, oppure cancella i dati di questo sito dalle impostazioni del browser.', 'storage.delete.workKeepsTemporary': 'Il tuo lavoro temporaneo attuale resta aperto.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'Proteggi con una password', + 'share.protect.optionHelp': 'Il diagramma viene cifrato dentro il link, e solo chi ha la password può aprirlo.', + 'share.protect.unavailable': 'La protezione con password richiede un browser aggiornato e un indirizzo sicuro (HTTPS), quindi qui non è disponibile.', + 'share.protect.password': 'Password', + 'share.protect.confirm': 'Ripeti la password', + 'share.protect.show': 'Mostra la password', + 'share.protect.hide': 'Nascondi la password', + 'share.protect.rule': 'Da 12 a 128 caratteri. Una frase lunga o più parole vanno bene.', + 'share.protect.error.short': 'La password deve avere almeno 12 caratteri.', + 'share.protect.error.long': 'La password può avere al massimo 128 caratteri.', + 'share.protect.error.mismatch': 'Le due password non coincidono.', + 'share.protect.note.channel': 'Invia la password per un’altra via rispetto al link, non nello stesso messaggio.', + 'share.protect.note.lost': 'Una password persa non si può recuperare. Non c’è un server e non c’è un ripristino.', + 'share.protect.note.strength': 'Il link è forte solo quanto la sua password: chi ha il link può continuare a tentare, senza limiti.', + 'share.protect.note.contract': 'Loop Studio non conserva né trasmette la password o la chiave. Il browser o un gestore di password può proporre di salvarla.', + 'share.protect.busy': 'Cifratura in corso…', + 'share.panel.protected': 'Questo link è protetto da una password. Invia la password separatamente.', + 'share.open.title': 'Apri un diagramma protetto', + 'share.open.body': 'Questo link è protetto da una password. Inserisci la password che ti ha dato chi lo ha inviato.', + 'share.open.submit': 'Apri', + 'share.open.busy': 'Apertura del diagramma…', + 'share.open.error.auth': 'La password è sbagliata, oppure il link è danneggiato. Controlla la password e riprova.', + 'share.open.note.personal': 'Una volta aperto, il diagramma viene conservato in questo browser come ogni altro diagramma.', + 'share.open.note.temporary': 'In questa sessione temporanea il diagramma aperto non viene conservato in questo browser.', + 'share.open.notice.title': 'Link protetto', + 'share.open.notice.damaged': 'Questo link protetto è danneggiato o incompleto e non si può aprire. Chiedi un nuovo link a chi lo ha inviato.', + 'share.open.notice.unavailable': 'Per aprire un link protetto servono un browser aggiornato e un indirizzo sicuro (HTTPS). Il link è ancora nella barra degli indirizzi, quindi si può aprire in un altro browser.', + 'share.open.notice.newer': 'Questo link protetto è stato creato con una versione più recente di Loop Studio. Aggiorna l’app e riapri il link: è ancora nella barra degli indirizzi.', + 'share.open.notice.content': 'La password è corretta, ma il link non contiene un diagramma che Loop Studio possa aprire. Chiedi un nuovo link a chi lo ha inviato.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'Un link di condivisione può essere protetto da una password: il diagramma viene cifrato dentro il link, e solo chi ha la password può aprirlo.', + 'whatsNew.v0170.open': 'Un link protetto chiede la password prima che venga mostrato qualcosa del diagramma condiviso. Una password sbagliata e un link danneggiato 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.', } satisfies Record export default ui diff --git a/src/i18n/locales/ja/ui.ts b/src/i18n/locales/ja/ui.ts index 162c525e..0907d4b5 100644 --- a/src/i18n/locales/ja/ui.ts +++ b/src/i18n/locales/ja/ui.ts @@ -497,6 +497,41 @@ const ui = { 'gate.temporary.rememberHelp': 'この起動時の選択だけが保存されます。', 'storage.delete.workFailed': '保存されたダイアグラムを削除できませんでした。このブラウザーにまだ残っている可能性があります。もう一度試すか、ブラウザー自体の設定でこのサイトのデータを消去してください。', 'storage.delete.workKeepsTemporary': '現在の一時的な作業はそのまま開いたままです。', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'パスワードで保護する', + 'share.protect.optionHelp': 'ダイアグラムはリンクの中で暗号化され、パスワードを持つ人だけが開けます。', + 'share.protect.unavailable': 'パスワード保護には最新のブラウザーと安全な(HTTPS)アドレスが必要なため、ここでは利用できません。', + 'share.protect.password': 'パスワード', + 'share.protect.confirm': 'パスワード(確認)', + 'share.protect.show': 'パスワードを表示', + 'share.protect.hide': 'パスワードを隠す', + 'share.protect.rule': '12~128文字。長い文や複数の単語をつなげたものが適しています。', + 'share.protect.error.short': 'パスワードは12文字以上にしてください。', + 'share.protect.error.long': 'パスワードは128文字以下にしてください。', + 'share.protect.error.mismatch': '2つのパスワードが一致しません。', + 'share.protect.note.channel': 'パスワードはリンクとは別の方法で送ってください。同じメッセージには入れないでください。', + 'share.protect.note.lost': '忘れたパスワードは復元できません。サーバーもリセットもありません。', + 'share.protect.note.strength': 'リンクの強さはパスワードの強さで決まります。リンクを持つ人は、回数の制限なく推測を続けられます。', + 'share.protect.note.contract': 'Loop Studio はパスワードも鍵も保存せず、送信もしません。ブラウザーやパスワードマネージャーが保存を提案することはあります。', + 'share.protect.busy': '暗号化しています…', + 'share.panel.protected': 'このリンクはパスワードで保護されています。パスワードは別に送ってください。', + 'share.open.title': '保護されたダイアグラムを開く', + 'share.open.body': 'このリンクはパスワードで保護されています。送信者から受け取ったパスワードを入力してください。', + 'share.open.submit': '開く', + 'share.open.busy': 'ダイアグラムを開いています…', + 'share.open.error.auth': 'パスワードが違うか、リンクが壊れています。パスワードを確認して、もう一度お試しください。', + 'share.open.note.personal': '開いたダイアグラムは、ほかのダイアグラムと同じようにこのブラウザーに保存されます。', + 'share.open.note.temporary': 'この一時セッションでは、開いたダイアグラムはこのブラウザーに残りません。', + 'share.open.notice.title': '保護されたリンク', + 'share.open.notice.damaged': 'この保護されたリンクは壊れているか不完全なため、開けません。送信者に新しいリンクを依頼してください。', + 'share.open.notice.unavailable': '保護されたリンクを開くには、最新のブラウザーと安全な(HTTPS)アドレスが必要です。リンクはアドレスバーに残っているので、別のブラウザーで開けます。', + 'share.open.notice.newer': 'この保護されたリンクは、より新しいバージョンの Loop Studio で作成されました。アプリを更新してから、もう一度リンクを開いてください。リンクはアドレスバーに残っています。', + 'share.open.notice.content': 'パスワードは正しいのですが、リンクには Loop Studio で開けるダイアグラムが含まれていません。送信者に新しいリンクを依頼してください。', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': '共有リンクをパスワードで保護できます。ダイアグラムはリンクの中で暗号化され、パスワードを持つ人だけが開けます。', + 'whatsNew.v0170.open': '保護されたリンクは、共有されたダイアグラムの内容を何も表示する前にパスワードを尋ねます。パスワードの誤りとリンクの破損では、同じメッセージが表示されます。', + 'whatsNew.v0170.limits': '忘れたパスワードは復元できず、リンクの強さはパスワードの強さで決まります。Loop Studio はパスワードも鍵も保存せず、送信もしません。', + 'whatsNew.v0170.plain': '通常のリンクが引き続き既定で、これまでどおり動作します。', } satisfies Record export default ui diff --git a/src/i18n/locales/ko/ui.ts b/src/i18n/locales/ko/ui.ts index 2b1513c3..3ee2b7f5 100644 --- a/src/i18n/locales/ko/ui.ts +++ b/src/i18n/locales/ko/ui.ts @@ -495,6 +495,11 @@ const ui = { 'share.open.notice.unavailable': '보호된 링크를 열려면 최신 브라우저와 보안(HTTPS) 주소가 필요합니다. 링크는 주소 표시줄에 그대로 있으니 다른 브라우저에서 열 수 있습니다.', 'share.open.notice.newer': '이 보호된 링크는 더 새로운 버전의 Loop Studio로 만들어졌습니다. 앱을 업데이트한 뒤 링크를 다시 여세요. 링크는 주소 표시줄에 그대로 있습니다.', 'share.open.notice.content': '비밀번호는 맞지만 링크에 Loop Studio가 열 수 있는 다이어그램이 들어 있지 않습니다. 보낸 사람에게 새 링크를 요청하세요.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': '공유 링크를 비밀번호로 보호할 수 있습니다. 다이어그램이 링크 안에 암호화되어 들어가며, 비밀번호를 가진 사람만 열 수 있습니다.', + 'whatsNew.v0170.open': '보호된 링크는 공유된 다이어그램의 어떤 것도 보이기 전에 비밀번호를 묻습니다. 틀린 비밀번호와 손상된 링크는 같은 안내가 나옵니다.', + 'whatsNew.v0170.limits': '잊은 비밀번호는 되찾을 수 없고, 링크는 비밀번호만큼만 안전합니다. Loop Studio는 비밀번호와 키를 저장하거나 전송하지 않습니다.', + 'whatsNew.v0170.plain': '일반 링크가 여전히 기본이며 이전과 같이 동작합니다.', } satisfies Record export default ui diff --git a/src/i18n/locales/nl/ui.ts b/src/i18n/locales/nl/ui.ts index 0b1bb5d8..6bfe4647 100644 --- a/src/i18n/locales/nl/ui.ts +++ b/src/i18n/locales/nl/ui.ts @@ -473,6 +473,41 @@ const ui = { 'gate.temporary.rememberHelp': 'Alleen deze startkeuze wordt opgeslagen.', 'storage.delete.workFailed': 'Het opgeslagen diagram kon niet worden verwijderd en staat mogelijk nog in deze browser. Probeer het opnieuw, of wis de gegevens van deze site in de instellingen van de browser zelf.', 'storage.delete.workKeepsTemporary': 'Je huidige tijdelijke werk blijft open.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'Beveiligen met een wachtwoord', + 'share.protect.optionHelp': 'Het diagram wordt in de link versleuteld, en alleen wie het wachtwoord heeft kan het openen.', + 'share.protect.unavailable': 'Wachtwoordbeveiliging vraagt om een actuele browser en een beveiligd (HTTPS-)adres en is hier daarom niet beschikbaar.', + 'share.protect.password': 'Wachtwoord', + 'share.protect.confirm': 'Wachtwoord nogmaals', + 'share.protect.show': 'Wachtwoord tonen', + 'share.protect.hide': 'Wachtwoord verbergen', + 'share.protect.rule': '12 tot 128 tekens. Een lange zin of een paar woorden werkt goed.', + 'share.protect.error.short': 'Het wachtwoord heeft minstens 12 tekens nodig.', + 'share.protect.error.long': 'Het wachtwoord mag hoogstens 128 tekens hebben.', + 'share.protect.error.mismatch': 'De twee wachtwoorden zijn niet gelijk.', + 'share.protect.note.channel': 'Stuur het wachtwoord langs een andere weg dan de link, niet in hetzelfde bericht.', + 'share.protect.note.lost': 'Een verloren wachtwoord kan niet worden teruggehaald. Er is geen server en geen herstelmogelijkheid.', + 'share.protect.note.strength': 'De link is maar zo sterk als het wachtwoord: wie de link heeft kan onbeperkt blijven raden.', + 'share.protect.note.contract': 'Loop Studio bewaart en verstuurt het wachtwoord en de sleutel niet. Je browser of een wachtwoordbeheerder kan aanbieden het op te slaan.', + 'share.protect.busy': 'Bezig met versleutelen…', + 'share.panel.protected': 'Deze link is beveiligd met een wachtwoord. Stuur het wachtwoord apart.', + 'share.open.title': 'Een beveiligd diagram openen', + 'share.open.body': 'Deze link is beveiligd met een wachtwoord. Voer het wachtwoord in dat je van de afzender hebt gekregen.', + 'share.open.submit': 'Openen', + 'share.open.busy': 'Diagram wordt geopend…', + 'share.open.error.auth': 'Het wachtwoord klopt niet, of de link is beschadigd. Controleer het wachtwoord en probeer het opnieuw.', + 'share.open.note.personal': 'Na het openen wordt het diagram in deze browser bewaard, net als elk ander diagram.', + 'share.open.note.temporary': 'In deze tijdelijke sessie wordt het geopende diagram niet in deze browser bewaard.', + 'share.open.notice.title': 'Beveiligde link', + 'share.open.notice.damaged': 'Deze beveiligde link is beschadigd of onvolledig en kan niet worden geopend. Vraag de afzender om een nieuwe link.', + 'share.open.notice.unavailable': 'Een beveiligde link openen vraagt om een actuele browser en een beveiligd (HTTPS-)adres. De link staat nog in de adresbalk en kan dus in een andere browser worden geopend.', + 'share.open.notice.newer': 'Deze beveiligde link is gemaakt met een nieuwere versie van Loop Studio. Werk de app bij en open de link opnieuw: hij staat nog in de adresbalk.', + 'share.open.notice.content': 'Het wachtwoord klopt, maar de link bevat geen diagram dat Loop Studio kan openen. Vraag de afzender om een nieuwe link.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'Een deellink kan met een wachtwoord worden beveiligd: het diagram wordt in de link versleuteld, en alleen wie het wachtwoord heeft kan het openen.', + 'whatsNew.v0170.open': 'Een beveiligde link vraagt om het wachtwoord voordat er iets van het gedeelde diagram wordt getoond. Een verkeerd wachtwoord en een beschadigde link 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.', } as const export default ui diff --git a/src/i18n/locales/pt-BR/ui.ts b/src/i18n/locales/pt-BR/ui.ts index bc664443..4b149399 100644 --- a/src/i18n/locales/pt-BR/ui.ts +++ b/src/i18n/locales/pt-BR/ui.ts @@ -478,6 +478,41 @@ const ui = { 'gate.temporary.rememberHelp': 'Apenas esta escolha de início é salva.', 'storage.delete.workFailed': 'Não foi possível remover o diagrama salvo, e ele ainda pode estar neste navegador. Tente de novo, ou limpe os dados deste site nas configurações do próprio navegador.', 'storage.delete.workKeepsTemporary': 'Seu trabalho temporário atual continua aberto.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'Proteger com uma senha', + 'share.protect.optionHelp': 'O diagrama é criptografado dentro do link, e só quem tem a senha pode abri-lo.', + 'share.protect.unavailable': 'A proteção por senha precisa de um navegador atual e de um endereço seguro (HTTPS), por isso não está disponível aqui.', + 'share.protect.password': 'Senha', + 'share.protect.confirm': 'Senha, de novo', + 'share.protect.show': 'Mostrar a senha', + 'share.protect.hide': 'Ocultar a senha', + 'share.protect.rule': 'De 12 a 128 caracteres. Uma frase longa ou várias palavras funcionam bem.', + 'share.protect.error.short': 'A senha precisa de pelo menos 12 caracteres.', + 'share.protect.error.long': 'A senha pode ter no máximo 128 caracteres.', + 'share.protect.error.mismatch': 'As duas senhas não são iguais.', + 'share.protect.note.channel': 'Envie a senha por outro caminho que não o do link, não na mesma mensagem.', + 'share.protect.note.lost': 'Uma senha perdida não pode ser recuperada. Não há servidor nem redefinição.', + 'share.protect.note.strength': 'O link é tão forte quanto a senha: quem tem o link pode continuar tentando, sem limite.', + 'share.protect.note.contract': 'O Loop Studio não armazena nem transmite a senha nem a chave. O navegador ou um gerenciador de senhas pode oferecer para salvá-la.', + 'share.protect.busy': 'Criptografando…', + 'share.panel.protected': 'Este link está protegido por senha. Envie a senha separadamente.', + 'share.open.title': 'Abrir um diagrama protegido', + 'share.open.body': 'Este link está protegido por senha. Digite a senha que quem o enviou passou para você.', + 'share.open.submit': 'Abrir', + 'share.open.busy': 'Abrindo o diagrama…', + 'share.open.error.auth': 'A senha está errada, ou o link está danificado. Confira a senha e tente de novo.', + 'share.open.note.personal': 'Depois de aberto, o diagrama fica guardado neste navegador como qualquer outro diagrama.', + 'share.open.note.temporary': 'Nesta sessão temporária o diagrama aberto não é mantido neste navegador.', + 'share.open.notice.title': 'Link protegido', + 'share.open.notice.damaged': 'Este link protegido está danificado ou incompleto e não pode ser aberto. Peça um novo link a quem o enviou.', + 'share.open.notice.unavailable': 'Abrir um link protegido precisa de um navegador atual e de um endereço seguro (HTTPS). O link continua na barra de endereços, então pode ser aberto em outro navegador.', + 'share.open.notice.newer': 'Este link protegido foi criado com uma versão mais nova do Loop Studio. Atualize o app e abra o link de novo: ele continua na barra de endereços.', + 'share.open.notice.content': 'A senha está certa, mas o link não contém um diagrama que o Loop Studio consiga abrir. Peça um novo link a quem o enviou.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'Um link de compartilhamento pode ser protegido com uma senha: o diagrama é criptografado dentro do link, e só quem tem a senha pode abri-lo.', + 'whatsNew.v0170.open': 'Um link protegido pede a senha antes de mostrar qualquer coisa do diagrama compartilhado. Uma senha errada e um link danificado 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.', } 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 13438347..546a8a95 100644 --- a/src/i18n/locales/pt-PT/ui.ts +++ b/src/i18n/locales/pt-PT/ui.ts @@ -492,6 +492,41 @@ const ui = { 'gate.temporary.rememberHelp': 'Apenas esta escolha de início é guardada.', 'storage.delete.workFailed': 'Não foi possível remover o diagrama guardado, e ele ainda pode estar neste navegador. Tente novamente, ou limpe os dados deste site nas definições do próprio navegador.', 'storage.delete.workKeepsTemporary': 'O seu trabalho temporário atual continua aberto.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'Proteger com uma palavra-passe', + 'share.protect.optionHelp': 'O diagrama é cifrado dentro da ligação, e só quem tem a palavra-passe o pode abrir.', + 'share.protect.unavailable': 'A proteção por palavra-passe precisa de um navegador atual e de um endereço seguro (HTTPS), por isso não está disponível aqui.', + 'share.protect.password': 'Palavra-passe', + 'share.protect.confirm': 'Palavra-passe, novamente', + 'share.protect.show': 'Mostrar a palavra-passe', + 'share.protect.hide': 'Ocultar a palavra-passe', + 'share.protect.rule': 'De 12 a 128 caracteres. Uma frase longa ou várias palavras funcionam bem.', + 'share.protect.error.short': 'A palavra-passe precisa de pelo menos 12 caracteres.', + 'share.protect.error.long': 'A palavra-passe pode ter no máximo 128 caracteres.', + 'share.protect.error.mismatch': 'As duas palavras-passe não são iguais.', + 'share.protect.note.channel': 'Envie a palavra-passe por outra via que não a da ligação, não na mesma mensagem.', + 'share.protect.note.lost': 'Uma palavra-passe perdida não pode ser recuperada. Não há servidor nem reposição.', + 'share.protect.note.strength': 'A ligação é tão forte quanto a palavra-passe: quem tem a ligação pode continuar a tentar, sem limite.', + 'share.protect.note.contract': 'O Loop Studio não armazena nem transmite a palavra-passe nem a chave. O navegador ou um gestor de palavras-passe pode propor guardá-la.', + 'share.protect.busy': 'A cifrar…', + 'share.panel.protected': 'Esta ligação está protegida por palavra-passe. Envie a palavra-passe em separado.', + 'share.open.title': 'Abrir um diagrama protegido', + 'share.open.body': 'Esta ligação está protegida por palavra-passe. Introduza a palavra-passe que lhe foi dada por quem a enviou.', + 'share.open.submit': 'Abrir', + 'share.open.busy': 'A abrir o diagrama…', + 'share.open.error.auth': 'A palavra-passe está errada, ou a ligação está danificada. Verifique a palavra-passe e tente novamente.', + 'share.open.note.personal': 'Depois de aberto, o diagrama fica guardado neste navegador como qualquer outro diagrama.', + 'share.open.note.temporary': 'Nesta sessão temporária o diagrama aberto não é mantido neste navegador.', + 'share.open.notice.title': 'Ligação protegida', + 'share.open.notice.damaged': 'Esta ligação protegida está danificada ou incompleta e não pode ser aberta. Peça uma nova ligação a quem a enviou.', + 'share.open.notice.unavailable': 'Abrir uma ligação protegida precisa de um navegador atual e de um endereço seguro (HTTPS). A ligação continua na barra de endereços, pelo que pode ser aberta noutro navegador.', + 'share.open.notice.newer': 'Esta ligação protegida foi criada com uma versão mais recente do Loop Studio. Atualize a aplicação e abra a ligação novamente: ela continua na barra de endereços.', + 'share.open.notice.content': 'A palavra-passe está certa, mas a ligação não contém um diagrama que o Loop Studio consiga abrir. Peça uma nova ligação a quem a enviou.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'Um link de partilha pode ser protegido com uma palavra-passe: o diagrama é cifrado dentro da ligação, e só quem tem a palavra-passe o pode abrir.', + 'whatsNew.v0170.open': 'Uma ligação protegida pede a palavra-passe antes de mostrar qualquer coisa do diagrama partilhado. Uma palavra-passe errada e uma ligação danificada 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.', } 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 3a510a79..c7ff4f4f 100644 --- a/src/i18n/locales/ru/ui.ts +++ b/src/i18n/locales/ru/ui.ts @@ -546,6 +546,41 @@ const ui = { 'gate.temporary.rememberHelp': 'Сохраняется только этот выбор при запуске.', 'storage.delete.workFailed': 'Сохранённую диаграмму не удалось удалить, и она может всё ещё находиться в этом браузере. Повторите попытку или очистите данные этого сайта в настройках самого браузера.', 'storage.delete.workKeepsTemporary': 'Ваша текущая временная работа остаётся открытой.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'Защитить паролем', + 'share.protect.optionHelp': 'Диаграмма шифруется внутри ссылки, и открыть её может только тот, у кого есть пароль.', + 'share.protect.unavailable': 'Для защиты паролем нужны современный браузер и защищённый адрес (HTTPS), поэтому здесь она недоступна.', + 'share.protect.password': 'Пароль', + 'share.protect.confirm': 'Пароль ещё раз', + 'share.protect.show': 'Показать пароль', + 'share.protect.hide': 'Скрыть пароль', + 'share.protect.rule': 'От 12 до 128 символов. Хорошо подходит длинная фраза или несколько слов.', + 'share.protect.error.short': 'В пароле должно быть не меньше 12 символов.', + 'share.protect.error.long': 'В пароле может быть не больше 128 символов.', + 'share.protect.error.mismatch': 'Пароли не совпадают.', + 'share.protect.note.channel': 'Отправьте пароль другим способом, не тем же сообщением, что и ссылку.', + 'share.protect.note.lost': 'Потерянный пароль нельзя восстановить. Нет ни сервера, ни сброса.', + 'share.protect.note.strength': 'Ссылка надёжна ровно настолько, насколько надёжен пароль: тот, у кого есть ссылка, может подбирать его без ограничений.', + 'share.protect.note.contract': 'Loop Studio не хранит и не передаёт ни пароль, ни ключ. Браузер или менеджер паролей может предложить сохранить его.', + 'share.protect.busy': 'Шифрование…', + 'share.panel.protected': 'Эта ссылка защищена паролем. Отправьте пароль отдельно.', + 'share.open.title': 'Открыть защищённую диаграмму', + 'share.open.body': 'Эта ссылка защищена паролем. Введите пароль, который дал вам отправитель.', + 'share.open.submit': 'Открыть', + 'share.open.busy': 'Диаграмма открывается…', + 'share.open.error.auth': 'Неверный пароль, или ссылка повреждена. Проверьте пароль и повторите попытку.', + 'share.open.note.personal': 'После открытия диаграмма сохраняется в этом браузере, как и любая другая.', + 'share.open.note.temporary': 'В этом временном сеансе открытая диаграмма не остаётся в этом браузере.', + 'share.open.notice.title': 'Защищённая ссылка', + 'share.open.notice.damaged': 'Эта защищённая ссылка повреждена или неполна, и открыть её нельзя. Попросите у отправителя новую ссылку.', + 'share.open.notice.unavailable': 'Чтобы открыть защищённую ссылку, нужны современный браузер и защищённый адрес (HTTPS). Ссылка по-прежнему в адресной строке, поэтому её можно открыть в другом браузере.', + 'share.open.notice.newer': 'Эта защищённая ссылка создана в более новой версии Loop Studio. Обновите приложение и откройте ссылку снова: она по-прежнему в адресной строке.', + 'share.open.notice.content': 'Пароль верный, но в ссылке нет диаграммы, которую Loop Studio может открыть. Попросите у отправителя новую ссылку.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'Ссылку для доступа можно защитить паролем: диаграмма шифруется внутри ссылки, и открыть её может только тот, у кого есть пароль.', + 'whatsNew.v0170.open': 'Защищённая ссылка спрашивает пароль до того, как будет показано что-либо из общей диаграммы. Неверный пароль и повреждённая ссылка дают одно и то же сообщение.', + 'whatsNew.v0170.limits': 'Потерянный пароль нельзя восстановить, а ссылка надёжна ровно настолько, насколько надёжен пароль. Loop Studio не хранит и не передаёт ни пароль, ни ключ.', + 'whatsNew.v0170.plain': 'Обычная ссылка остаётся вариантом по умолчанию и работает как раньше.', } 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 9148cc62..e7c12766 100644 --- a/src/i18n/locales/th/ui.ts +++ b/src/i18n/locales/th/ui.ts @@ -538,6 +538,41 @@ const ui = { 'gate.temporary.rememberHelp': 'จะบันทึกเฉพาะตัวเลือกตอนเปิดนี้เท่านั้น', 'storage.delete.workFailed': 'ไม่สามารถลบไดอะแกรมที่บันทึกไว้ได้ และอาจยังอยู่ในเบราว์เซอร์นี้ ลองอีกครั้ง หรือล้างข้อมูลของเว็บไซต์นี้ในการตั้งค่าของเบราว์เซอร์เอง', 'storage.delete.workKeepsTemporary': 'งานชั่วคราวปัจจุบันของคุณยังคงเปิดอยู่', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'ป้องกันด้วยรหัสผ่าน', + 'share.protect.optionHelp': 'ไดอะแกรมจะถูกเข้ารหัสไว้ในลิงก์ และมีเพียงผู้ที่มีรหัสผ่านเท่านั้นที่เปิดได้', + 'share.protect.unavailable': 'การป้องกันด้วยรหัสผ่านต้องใช้เบราว์เซอร์รุ่นปัจจุบันและที่อยู่ที่ปลอดภัย (HTTPS) จึงใช้ที่นี่ไม่ได้', + 'share.protect.password': 'รหัสผ่าน', + 'share.protect.confirm': 'รหัสผ่านอีกครั้ง', + 'share.protect.show': 'แสดงรหัสผ่าน', + 'share.protect.hide': 'ซ่อนรหัสผ่าน', + 'share.protect.rule': '12 ถึง 128 อักขระ ประโยคยาว ๆ หรือคำหลายคำต่อกันใช้ได้ดี', + 'share.protect.error.short': 'รหัสผ่านต้องมีอย่างน้อย 12 อักขระ', + 'share.protect.error.long': 'รหัสผ่านมีได้ไม่เกิน 128 อักขระ', + 'share.protect.error.mismatch': 'รหัสผ่านทั้งสองไม่ตรงกัน', + 'share.protect.note.channel': 'ส่งรหัสผ่านทางช่องทางอื่นที่ไม่ใช่ช่องทางเดียวกับลิงก์ อย่าส่งในข้อความเดียวกัน', + 'share.protect.note.lost': 'รหัสผ่านที่หายไปกู้คืนไม่ได้ ไม่มีเซิร์ฟเวอร์และไม่มีการรีเซ็ต', + 'share.protect.note.strength': 'ลิงก์ปลอดภัยได้เท่ากับรหัสผ่านเท่านั้น ใครก็ตามที่มีลิงก์สามารถเดารหัสผ่านต่อไปได้โดยไม่จำกัด', + 'share.protect.note.contract': 'Loop Studio ไม่จัดเก็บและไม่ส่งรหัสผ่านหรือกุญแจ เบราว์เซอร์หรือตัวจัดการรหัสผ่านอาจเสนอให้บันทึกรหัสผ่าน', + 'share.protect.busy': 'กำลังเข้ารหัส…', + 'share.panel.protected': 'ลิงก์นี้ป้องกันด้วยรหัสผ่าน ส่งรหัสผ่านแยกต่างหาก', + 'share.open.title': 'เปิดไดอะแกรมที่ป้องกันไว้', + 'share.open.body': 'ลิงก์นี้ป้องกันด้วยรหัสผ่าน ป้อนรหัสผ่านที่ผู้ส่งให้คุณ', + 'share.open.submit': 'เปิด', + 'share.open.busy': 'กำลังเปิดไดอะแกรม…', + 'share.open.error.auth': 'รหัสผ่านไม่ถูกต้อง หรือลิงก์เสียหาย ตรวจสอบรหัสผ่านแล้วลองอีกครั้ง', + 'share.open.note.personal': 'เมื่อเปิดแล้ว ไดอะแกรมจะถูกเก็บไว้ในเบราว์เซอร์นี้เหมือนไดอะแกรมอื่น ๆ', + 'share.open.note.temporary': 'ในเซสชันชั่วคราวนี้ ไดอะแกรมที่เปิดจะไม่ถูกเก็บไว้ในเบราว์เซอร์นี้', + 'share.open.notice.title': 'ลิงก์ที่ป้องกันไว้', + 'share.open.notice.damaged': 'ลิงก์ที่ป้องกันไว้นี้เสียหายหรือไม่ครบถ้วน จึงเปิดไม่ได้ ขอลิงก์ใหม่จากผู้ส่ง', + 'share.open.notice.unavailable': 'การเปิดลิงก์ที่ป้องกันไว้ต้องใช้เบราว์เซอร์รุ่นปัจจุบันและที่อยู่ที่ปลอดภัย (HTTPS) ลิงก์ยังอยู่ในแถบที่อยู่ จึงเปิดในเบราว์เซอร์อื่นได้', + 'share.open.notice.newer': 'ลิงก์ที่ป้องกันไว้นี้สร้างด้วย Loop Studio รุ่นที่ใหม่กว่า อัปเดตแอปแล้วเปิดลิงก์อีกครั้ง ลิงก์ยังอยู่ในแถบที่อยู่', + 'share.open.notice.content': 'รหัสผ่านถูกต้อง แต่ลิงก์ไม่มีไดอะแกรมที่ Loop Studio เปิดได้ ขอลิงก์ใหม่จากผู้ส่ง', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'ลิงก์แชร์ป้องกันด้วยรหัสผ่านได้ ไดอะแกรมจะถูกเข้ารหัสไว้ในลิงก์ และมีเพียงผู้ที่มีรหัสผ่านเท่านั้นที่เปิดได้', + 'whatsNew.v0170.open': 'ลิงก์ที่ป้องกันไว้จะถามรหัสผ่านก่อนแสดงสิ่งใดจากไดอะแกรมที่แชร์ รหัสผ่านที่ผิดและลิงก์ที่เสียหายจะได้ข้อความเดียวกัน', + 'whatsNew.v0170.limits': 'รหัสผ่านที่หายไปกู้คืนไม่ได้ และลิงก์ปลอดภัยได้เท่ากับรหัสผ่านเท่านั้น Loop Studio ไม่จัดเก็บและไม่ส่งรหัสผ่านหรือกุญแจ', + 'whatsNew.v0170.plain': 'ลิงก์ธรรมดายังคงเป็นค่าเริ่มต้นและทำงานเหมือนเดิม', } 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 c27d7491..df5d2d11 100644 --- a/src/i18n/locales/tr/ui.ts +++ b/src/i18n/locales/tr/ui.ts @@ -532,6 +532,41 @@ const ui = { 'gate.temporary.rememberHelp': 'Yalnızca bu başlangıç seçimi kaydedilir.', 'storage.delete.workFailed': 'Kayıtlı diyagram kaldırılamadı ve hâlâ bu tarayıcıda olabilir. Yeniden deneyin veya bu sitenin verilerini tarayıcının kendi ayarlarından temizleyin.', 'storage.delete.workKeepsTemporary': 'Geçerli geçici çalışmanız açık kalır.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'Parolayla koru', + 'share.protect.optionHelp': 'Diyagram bağlantının içinde şifrelenir ve onu yalnızca parolayı bilen biri açabilir.', + 'share.protect.unavailable': 'Parola koruması güncel bir tarayıcı ve güvenli (HTTPS) bir adres gerektirir; bu yüzden burada kullanılamaz.', + 'share.protect.password': 'Parola', + 'share.protect.confirm': 'Parola (tekrar)', + 'share.protect.show': 'Parolayı göster', + 'share.protect.hide': 'Parolayı gizle', + 'share.protect.rule': '12 ile 128 karakter arası. Uzun bir cümle veya birkaç sözcük iyi olur.', + 'share.protect.error.short': 'Parola en az 12 karakter olmalı.', + 'share.protect.error.long': 'Parola en fazla 128 karakter olabilir.', + 'share.protect.error.mismatch': 'İki parola aynı değil.', + 'share.protect.note.channel': 'Parolayı bağlantıdan farklı bir yolla gönderin; aynı mesajda göndermeyin.', + 'share.protect.note.lost': 'Kaybedilen parola kurtarılamaz. Sunucu da yok, sıfırlama da.', + 'share.protect.note.strength': 'Bağlantı ancak parolası kadar güçlüdür: bağlantıya sahip olan biri sınırsız kez tahmin etmeyi sürdürebilir.', + 'share.protect.note.contract': 'Loop Studio parolayı da anahtarı da saklamaz ve iletmez. Tarayıcınız veya bir parola yöneticisi onu kaydetmeyi önerebilir.', + 'share.protect.busy': 'Şifreleniyor…', + 'share.panel.protected': 'Bu bağlantı parolayla korunuyor. Parolayı ayrı gönderin.', + 'share.open.title': 'Korumalı bir diyagramı aç', + 'share.open.body': 'Bu bağlantı parolayla korunuyor. Gönderenin size verdiği parolayı girin.', + 'share.open.submit': 'Aç', + 'share.open.busy': 'Diyagram açılıyor…', + 'share.open.error.auth': 'Parola yanlış veya bağlantı hasarlı. Parolayı kontrol edip yeniden deneyin.', + 'share.open.note.personal': 'Açıldıktan sonra diyagram, diğer diyagramlar gibi bu tarayıcıda tutulur.', + 'share.open.note.temporary': 'Bu geçici oturumda açılan diyagram bu tarayıcıda tutulmaz.', + 'share.open.notice.title': 'Korumalı bağlantı', + 'share.open.notice.damaged': 'Bu korumalı bağlantı hasarlı veya eksik olduğu için açılamıyor. Gönderenden yeni bir bağlantı isteyin.', + 'share.open.notice.unavailable': 'Korumalı bir bağlantıyı açmak için güncel bir tarayıcı ve güvenli (HTTPS) bir adres gerekir. Bağlantı hâlâ adres çubuğunda, bu yüzden başka bir tarayıcıda açılabilir.', + 'share.open.notice.newer': 'Bu korumalı bağlantı Loop Studio’nun daha yeni bir sürümüyle oluşturulmuş. Uygulamayı güncelleyip bağlantıyı yeniden açın: bağlantı hâlâ adres çubuğunda.', + 'share.open.notice.content': 'Parola doğru, ancak bağlantıda Loop Studio’nun açabileceği bir diyagram yok. Gönderenden yeni bir bağlantı isteyin.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'Paylaşım bağlantısı parolayla korunabilir: diyagram bağlantının içinde şifrelenir ve onu yalnızca parolayı bilen biri açabilir.', + 'whatsNew.v0170.open': 'Korumalı bağlantı, paylaşılan diyagramdan herhangi bir şey gösterilmeden önce parolasını sorar. Yanlış parola ile hasarlı bağlantı 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.', } 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 d70784f4..64185210 100644 --- a/src/i18n/locales/vi/ui.ts +++ b/src/i18n/locales/vi/ui.ts @@ -478,6 +478,41 @@ const ui = { 'gate.temporary.rememberHelp': 'Chỉ lựa chọn khởi động này được lưu.', 'storage.delete.workFailed': 'Không thể xóa sơ đồ đã lưu, và nó có thể vẫn còn trong trình duyệt này. Hãy thử lại, hoặc xóa dữ liệu của trang này trong cài đặt của chính trình duyệt.', 'storage.delete.workKeepsTemporary': 'Công việc tạm thời hiện tại của bạn vẫn mở.', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': 'Bảo vệ bằng mật khẩu', + 'share.protect.optionHelp': 'Sơ đồ được mã hóa ngay trong liên kết, và chỉ người có mật khẩu mới mở được.', + 'share.protect.unavailable': 'Bảo vệ bằng mật khẩu cần một trình duyệt hiện hành và một địa chỉ an toàn (HTTPS), nên không dùng được ở đây.', + 'share.protect.password': 'Mật khẩu', + 'share.protect.confirm': 'Nhập lại mật khẩu', + 'share.protect.show': 'Hiện mật khẩu', + 'share.protect.hide': 'Ẩn mật khẩu', + 'share.protect.rule': 'Từ 12 đến 128 ký tự. Một câu dài hoặc vài từ ghép lại là phù hợp.', + 'share.protect.error.short': 'Mật khẩu cần ít nhất 12 ký tự.', + 'share.protect.error.long': 'Mật khẩu có tối đa 128 ký tự.', + 'share.protect.error.mismatch': 'Hai mật khẩu không giống nhau.', + 'share.protect.note.channel': 'Hãy gửi mật khẩu bằng một cách khác với liên kết, không gửi trong cùng một tin nhắn.', + 'share.protect.note.lost': 'Mật khẩu đã mất thì không thể khôi phục. Không có máy chủ và không có cách đặt lại.', + 'share.protect.note.strength': 'Liên kết chỉ an toàn bằng chính mật khẩu của nó: ai có liên kết đều có thể đoán mãi, không giới hạn.', + 'share.protect.note.contract': 'Loop Studio không lưu trữ và không truyền đi mật khẩu hay khóa. Trình duyệt hoặc trình quản lý mật khẩu có thể đề nghị lưu mật khẩu.', + 'share.protect.busy': 'Đang mã hóa…', + 'share.panel.protected': 'Liên kết này được bảo vệ bằng mật khẩu. Hãy gửi mật khẩu riêng.', + 'share.open.title': 'Mở sơ đồ được bảo vệ', + 'share.open.body': 'Liên kết này được bảo vệ bằng mật khẩu. Hãy nhập mật khẩu mà người gửi đã đưa cho bạn.', + 'share.open.submit': 'Mở', + 'share.open.busy': 'Đang mở sơ đồ…', + 'share.open.error.auth': 'Mật khẩu sai, hoặc liên kết bị hỏng. Hãy kiểm tra mật khẩu và thử lại.', + 'share.open.note.personal': 'Sau khi mở, sơ đồ được giữ trong trình duyệt này như mọi sơ đồ khác.', + 'share.open.note.temporary': 'Trong phiên tạm thời này, sơ đồ đã mở không được giữ trong trình duyệt này.', + 'share.open.notice.title': 'Liên kết được bảo vệ', + 'share.open.notice.damaged': 'Liên kết được bảo vệ này bị hỏng hoặc không đầy đủ nên không mở được. Hãy xin người gửi một liên kết mới.', + 'share.open.notice.unavailable': 'Để mở liên kết được bảo vệ cần một trình duyệt hiện hành và một địa chỉ an toàn (HTTPS). Liên kết vẫn còn trên thanh địa chỉ, nên có thể mở bằng trình duyệt khác.', + 'share.open.notice.newer': 'Liên kết được bảo vệ này được tạo bằng phiên bản Loop Studio mới hơn. Hãy cập nhật ứng dụng rồi mở lại liên kết: nó vẫn còn trên thanh địa chỉ.', + 'share.open.notice.content': 'Mật khẩu đúng, nhưng liên kết không chứa sơ đồ mà Loop Studio mở được. Hãy xin người gửi một liên kết mới.', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': 'Liên kết chia sẻ có thể được bảo vệ bằng mật khẩu: sơ đồ được mã hóa ngay trong liên kết, và chỉ người có mật khẩu mới mở được.', + '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à liên kết hỏng 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.', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hans/ui.ts b/src/i18n/locales/zh-Hans/ui.ts index be99b840..510e1381 100644 --- a/src/i18n/locales/zh-Hans/ui.ts +++ b/src/i18n/locales/zh-Hans/ui.ts @@ -470,6 +470,41 @@ const ui = { 'gate.temporary.rememberHelp': '只会保存这一个启动选择。', 'storage.delete.workFailed': '无法删除保存的图表,它可能仍在这个浏览器中。请重试,或在浏览器自身的设置中清除此站点的数据。', 'storage.delete.workKeepsTemporary': '你当前的临时工作保持打开。', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': '用密码保护', + 'share.protect.optionHelp': '图表会在链接内加密,只有持有密码的人才能打开。', + 'share.protect.unavailable': '密码保护需要较新的浏览器和安全(HTTPS)地址,因此在这里无法使用。', + 'share.protect.password': '密码', + 'share.protect.confirm': '再次输入密码', + 'share.protect.show': '显示密码', + 'share.protect.hide': '隐藏密码', + 'share.protect.rule': '12 到 128 个字符。一个长句子或几个词连在一起效果很好。', + 'share.protect.error.short': '密码至少需要 12 个字符。', + 'share.protect.error.long': '密码最多 128 个字符。', + 'share.protect.error.mismatch': '两次输入的密码不一致。', + 'share.protect.note.channel': '请通过与链接不同的途径发送密码,不要放在同一条消息里。', + 'share.protect.note.lost': '密码丢失后无法找回。这里没有服务器,也没有重置。', + 'share.protect.note.strength': '链接的安全程度取决于密码:拿到链接的人可以不受限制地一直猜下去。', + 'share.protect.note.contract': 'Loop Studio 不存储也不传输密码或密钥。浏览器或密码管理器可能会提议保存它。', + 'share.protect.busy': '正在加密…', + 'share.panel.protected': '此链接受密码保护。请另行发送密码。', + 'share.open.title': '打开受保护的图表', + 'share.open.body': '此链接受密码保护。请输入发送者给你的密码。', + 'share.open.submit': '打开', + 'share.open.busy': '正在打开图表…', + 'share.open.error.auth': '密码错误,或者链接已损坏。请检查密码后重试。', + 'share.open.note.personal': '打开后,图表会像其他图表一样保留在这个浏览器中。', + 'share.open.note.temporary': '在这个临时会话中,打开的图表不会留在这个浏览器里。', + 'share.open.notice.title': '受保护的链接', + 'share.open.notice.damaged': '这个受保护的链接已损坏或不完整,无法打开。请向发送者索取新链接。', + 'share.open.notice.unavailable': '打开受保护的链接需要较新的浏览器和安全(HTTPS)地址。链接仍在地址栏中,可以在其他浏览器中打开。', + 'share.open.notice.newer': '这个受保护的链接是用更新版本的 Loop Studio 创建的。请更新应用后重新打开链接:它仍在地址栏中。', + 'share.open.notice.content': '密码正确,但链接中没有 Loop Studio 能打开的图表。请向发送者索取新链接。', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': '分享链接可以用密码保护:图表会在链接内加密,只有持有密码的人才能打开。', + 'whatsNew.v0170.open': '受保护的链接会先询问密码,之后才显示共享图表的任何内容。密码错误和链接损坏给出的是同一条提示。', + 'whatsNew.v0170.limits': '密码丢失后无法找回,链接的安全程度取决于密码。Loop Studio 不存储也不传输密码或密钥。', + 'whatsNew.v0170.plain': '普通链接仍是默认选项,用法与之前相同。', } satisfies Record export default ui diff --git a/src/i18n/locales/zh-Hant/ui.ts b/src/i18n/locales/zh-Hant/ui.ts index 9cffc441..c2ecbc4d 100644 --- a/src/i18n/locales/zh-Hant/ui.ts +++ b/src/i18n/locales/zh-Hant/ui.ts @@ -496,6 +496,41 @@ const ui = { 'gate.temporary.rememberHelp': '只會保存這一個啟動選擇。', 'storage.delete.workFailed': '無法刪除保存的圖表,它可能仍在這個瀏覽器中。請重試,或在瀏覽器自身的設定中清除此網站的資料。', 'storage.delete.workKeepsTemporary': '你目前的臨時工作保持開啟。', + // issue #300 - password protection for share links (loop-share-protected/1) + 'share.protect.option': '用密碼保護', + 'share.protect.optionHelp': '圖表會在連結內加密,只有持有密碼的人才能開啟。', + 'share.protect.unavailable': '密碼保護需要較新的瀏覽器和安全(HTTPS)位址,因此在這裡無法使用。', + 'share.protect.password': '密碼', + 'share.protect.confirm': '再次輸入密碼', + 'share.protect.show': '顯示密碼', + 'share.protect.hide': '隱藏密碼', + 'share.protect.rule': '12 到 128 個字元。一個長句子或幾個詞連在一起效果很好。', + 'share.protect.error.short': '密碼至少需要 12 個字元。', + 'share.protect.error.long': '密碼最多 128 個字元。', + 'share.protect.error.mismatch': '兩次輸入的密碼不一致。', + 'share.protect.note.channel': '請透過與連結不同的途徑傳送密碼,不要放在同一則訊息裡。', + 'share.protect.note.lost': '密碼遺失後無法找回。這裡沒有伺服器,也沒有重設。', + 'share.protect.note.strength': '連結的安全程度取決於密碼:拿到連結的人可以不受限制地一直猜下去。', + 'share.protect.note.contract': 'Loop Studio 不保存也不傳輸密碼或金鑰。瀏覽器或密碼管理器可能會提議保存它。', + 'share.protect.busy': '正在加密…', + 'share.panel.protected': '此連結受密碼保護。請另行傳送密碼。', + 'share.open.title': '開啟受保護的圖表', + 'share.open.body': '此連結受密碼保護。請輸入傳送者給你的密碼。', + 'share.open.submit': '開啟', + 'share.open.busy': '正在開啟圖表…', + 'share.open.error.auth': '密碼錯誤,或者連結已損壞。請檢查密碼後重試。', + 'share.open.note.personal': '開啟後,圖表會像其他圖表一樣保留在這個瀏覽器中。', + 'share.open.note.temporary': '在這個臨時工作階段中,開啟的圖表不會留在這個瀏覽器裡。', + 'share.open.notice.title': '受保護的連結', + 'share.open.notice.damaged': '這個受保護的連結已損壞或不完整,無法開啟。請向傳送者索取新連結。', + 'share.open.notice.unavailable': '開啟受保護的連結需要較新的瀏覽器和安全(HTTPS)位址。連結仍在網址列中,可以在其他瀏覽器中開啟。', + 'share.open.notice.newer': '這個受保護的連結是用更新版本的 Loop Studio 建立的。請更新應用程式後重新開啟連結:它仍在網址列中。', + 'share.open.notice.content': '密碼正確,但連結中沒有 Loop Studio 能開啟的圖表。請向傳送者索取新連結。', + // issue #300 - the 0.17.0 release notes: password protection for share links + 'whatsNew.v0170.protect': '分享連結可以用密碼保護:圖表會在連結內加密,只有持有密碼的人才能開啟。', + 'whatsNew.v0170.open': '受保護的連結會先詢問密碼,之後才顯示共享圖表的任何內容。密碼錯誤和連結損壞給出的是同一則提示。', + 'whatsNew.v0170.limits': '密碼遺失後無法找回,連結的安全程度取決於密碼。Loop Studio 不保存也不傳輸密碼或金鑰。', + 'whatsNew.v0170.plain': '一般連結仍是預設選項,用法與之前相同。', } satisfies Record export default ui diff --git a/src/i18n/nlCopy.test.ts b/src/i18n/nlCopy.test.ts index 4ff94b66..6bab2fb5 100644 --- a/src/i18n/nlCopy.test.ts +++ b/src/i18n/nlCopy.test.ts @@ -181,6 +181,8 @@ const SURVIVING: ReadonlyArray = [ // issue #297 - the storage gate and the Storage and privacy area: `computer` // and `privacy` are ordinary Dutch, Chrome and HTML are names ['the storage gate (issue #297)', ['computer', 'privacy', 'chrome', 'html']], + // issue #300 - password protection for share links: HTTPS is a protocol name + ['password protection for share links (issue #300)', ['https']], ] const SURVIVING_WORDS = new Set(SURVIVING.flatMap(([, ws]) => ws)) @@ -253,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(944) + expect(KEYS).toHaveLength(977) 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: 944, template: 196, frame: 7, module: 19 }) - expect(RUNTIME).toHaveLength(1166) + expect(per).toEqual({ catalog: 977, template: 196, frame: 7, module: 19 }) + expect(RUNTIME).toHaveLength(1199) }) 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 44156660..41645862 100644 --- a/src/i18n/ptPtCopy.test.ts +++ b/src/i18n/ptPtCopy.test.ts @@ -56,7 +56,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(944) + expect(KEYS).toHaveLength(977) }) it('differs from pt-BR on exactly the audited keys', () => { @@ -103,9 +103,17 @@ describe('pt-PT copy — the region audit over pt-BR', () => { // not `configurações`, `aplicação` not `aplicativo`, `num` not `em um`. // Then 224 to 227: the corrected temporary-session copy and the failed- // deletion notice, on the same axes (`guardado`, `definições`, `ficheiro`). - expect(DELTA).toHaveLength(227) - // and it is a real audit, not a rewrite — most of the catalog agrees - expect(DELTA.length).toBeLessThan(KEYS.length / 4) + // Issue #300 moved it from 227 to 255: the password dialogs and the 0.17.0 + // 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) + // and it is a real audit, not a rewrite — most of the catalog agrees. + // The bound was a quarter until issue #300. The word for a password itself + // differs (`palavra-passe` / `senha`), so 28 of that issue's 33 strings + // differ for one reason, and the share reached 26%. The bound is 30% now; + // the count above is still the exact pin. + expect(DELTA.length).toBeLessThan(KEYS.length * 0.3) }) // ------------------------------------------------- direction 1: nothing left diff --git a/src/i18n/ruCopy.test.ts b/src/i18n/ruCopy.test.ts index 1707cd30..43db2e71 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(944) + expect(KEYS).toHaveLength(977) }) // ----------------------------------------------------------------- script @@ -218,9 +218,16 @@ describe('ru copy — the first Cyrillic catalog', () => { 'задаёт', 'задаётся', 'заменён', + // issue #300: the password dialogs (`защищённая ссылка`, `надёжен`, + // `не передаёт`, `повреждённая ссылка`), here and below + 'защищённая', + 'защищённую', + 'защищённый', 'идёт', 'изменён', 'моё', + 'надёжен', + 'надёжна', 'надёжного', 'несохранённые', 'несёт', @@ -228,8 +235,10 @@ describe('ru copy — the first Cyrillic catalog', () => { 'обновлён', 'объединённые', 'остаётся', + 'передаёт', 'передаётся', 'перемещён', + 'повреждённая', 'подключён', 'прервёт', 'прошёл', diff --git a/src/i18n/thCopy.test.ts b/src/i18n/thCopy.test.ts index 1b8582d3..2e4330e2 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(944) + expect(KEYS).toHaveLength(977) }) it('covers all three runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(944) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(977) 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) @@ -187,6 +187,7 @@ const LATIN_WIRE = [ 'id', 'name', 'price', 'rate', 'drop', 'item', 'n', 's', 'x', 'y', 'v', 'v1', 'v2', 'g1', 'd6', 'all', 'p10', 'p50', 'p90', 'resource', 'state', 'timing', 'when', + 'https', // issue #300: the protocol the password dialogs name ] /** Placeholder examples and the gacha template's own proper nouns — the banner * zone names and the rarity letters, which are the graph's vocabulary, not @@ -321,6 +322,12 @@ const LATIN_KEYS = [ 'catalog:rf.node.moveCancelled', 'catalog:rf.node.moved', 'catalog:runbar.mc.cancel', + // issue #300: `Loop Studio` and `HTTPS` in the password dialogs + 'catalog:share.open.notice.content', + 'catalog:share.open.notice.newer', + 'catalog:share.open.notice.unavailable', + 'catalog:share.protect.note.contract', + 'catalog:share.protect.unavailable', 'catalog:share.tooLarge', 'catalog:stateExpr.label.hint.empty', 'catalog:stateExpr.label.hint.notAnAssignment', @@ -392,6 +399,7 @@ const LATIN_KEYS = [ 'template/mmo-progression:z2_xp_meter', 'template/mmo-progression:z3_xp_meter', 'catalog:whatsNew.v0153.icons', + 'catalog:whatsNew.v0170.limits', // the storage gate and the Storage and privacy area (issue #297) 'catalog:gate.lead', 'catalog:gate.portable.note', @@ -409,7 +417,7 @@ describe('a Latin run is declared, twice over — by key and by word', () => { it('the strings carrying a Latin run are exactly the declared ones', () => { expect(withLatin.slice().sort()).toEqual(LATIN_KEYS.slice().sort()) - expect(LATIN_KEYS).toHaveLength(156) + expect(LATIN_KEYS).toHaveLength(162) }) it('the Latin vocabulary is exactly the declared one', () => { @@ -417,14 +425,14 @@ describe('a Latin run is declared, twice over — by key and by word', () => { for (const [, , v] of RUNTIME) for (const w of latinWords(v)) seen.add(w) const declared = new Set([...LATIN_GLOBAL, ...Object.keys(LATIN_KEY_SCOPED)]) expect([...seen].sort()).toEqual([...declared].sort()) - expect(declared.size).toBe(76) + expect(declared.size).toBe(77) }) it('the groups are disjoint, and they add up', () => { // Otherwise a word could be quietly moved between groups, and the reason // it is allowed — which is the whole content of this list — would rot. const counts = LATIN_GROUPS.map(([, g]) => g.length) - expect(counts).toEqual([16, 11, 11, 23, 12]) + expect(counts).toEqual([16, 11, 11, 24, 12]) const all = LATIN_GROUPS.flatMap(([, g]) => g) expect(all).toHaveLength(LATIN_GLOBAL.size) for (const w of Object.keys(LATIN_KEY_SCOPED)) expect(LATIN_GLOBAL.has(w)).toBe(false) diff --git a/src/i18n/trCopy.test.ts b/src/i18n/trCopy.test.ts index 0ae3ae0b..9d5e2183 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(944) + expect(KEYS).toHaveLength(977) // issue #300: +33 }) }) diff --git a/src/i18n/viCopy.test.ts b/src/i18n/viCopy.test.ts index 5c4ec6b7..a905d26a 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(944) + expect(KEYS).toHaveLength(977) }) it('covers all four runtime surfaces', () => { - expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(944) + expect(RUNTIME.filter(([s]) => s === 'catalog')).toHaveLength(977) 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) @@ -280,6 +280,7 @@ const ASCII_KEYBOARD = [ const ASCII_WIRE = [ 'id', 'n', 's', 'x', 'y', 'v1', 'v2', 'g1', 'd6', 'all', 'p10', 'p50', 'p90', 'resource', 'state', 'timing', 'when', + 'https', // issue #300: the protocol the password dialogs name ] /** The spreadsheet column names in the quick-start mapping example. */ const ASCII_COLUMN = ['item_id', 'item_name', 'price', 'drop_rate'] @@ -449,6 +450,12 @@ const ASCII_KEYS = [ 'catalog:rf.node.moveCancelled', 'catalog:rf.node.moved', 'catalog:runbar.mc.cancel', + // issue #300: `Loop Studio` and `HTTPS` in the password dialogs + 'catalog:share.open.notice.content', + 'catalog:share.open.notice.newer', + 'catalog:share.open.notice.unavailable', + 'catalog:share.protect.note.contract', + 'catalog:share.protect.unavailable', 'catalog:share.tooLarge', 'catalog:stateExpr.label.hint.empty', 'catalog:stateExpr.label.hint.notAnAssignment', @@ -527,6 +534,7 @@ const ASCII_KEYS = [ 'template/mmo-progression:z2_xp_meter', 'template/mmo-progression:z3_xp_meter', 'catalog:whatsNew.v0153.icons', + 'catalog:whatsNew.v0170.limits', // the storage gate and the Storage and privacy area (issue #297) 'catalog:gate.lead', 'catalog:gate.portable.note', @@ -548,7 +556,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(176) + expect(ASCII_KEYS).toHaveLength(182) }) it('the kept vocabulary is exactly the declared one', () => { @@ -556,13 +564,13 @@ describe('a kept English word is declared, twice over — by key and by word', ( for (const [s, id, v] of RUNTIME) for (const w of sharedWith(s, id, v)) seen.add(w) const declared = new Set([...ASCII_GLOBAL, ...Object.keys(ASCII_KEY_SCOPED)]) expect([...seen].sort()).toEqual([...declared].sort()) - expect(declared.size).toBe(80) + expect(declared.size).toBe(81) }) it('the groups are disjoint, and they add up', () => { // Otherwise a word could be quietly moved between groups, and the reason it // is kept — which is the whole content of this list — would rot. - expect(ASCII_GROUPS.map(([, g]) => g.length)).toEqual([17, 8, 12, 17, 4, 5, 12, 2]) + expect(ASCII_GROUPS.map(([, g]) => g.length)).toEqual([17, 8, 12, 18, 4, 5, 12, 2]) const all = ASCII_GROUPS.flatMap(([, g]) => g) expect(all).toHaveLength(ASCII_GLOBAL.size) for (const w of Object.keys(ASCII_KEY_SCOPED)) expect(ASCII_GLOBAL.has(w)).toBe(false) diff --git a/src/releaseNotes/releaseNotes.ts b/src/releaseNotes/releaseNotes.ts index 46767200..4be8c2e0 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[] = [ + { + // the date is the day it is deployed, set right before the merge (issue #300) + id: 'release:0.17.0', + version: '0.17.0', + date: '2026-10-04', + items: ['whatsNew.v0170.protect', 'whatsNew.v0170.open', 'whatsNew.v0170.limits', 'whatsNew.v0170.plain'], + }, { // the date is the day it is deployed, set right before the merge (issue #297) id: 'release:0.16.0', From a97853b5dc82c42d3c3fe61336d6160dffeadc43 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:21:10 +0900 Subject: [PATCH 5/8] test(share): protected links end to end, and the specification with its errata Part of #300. The last of five staged commits. `e2e/share-protected.spec.ts` drives the feature through the real UI: the plain link is still the default with focus on Cancel; the rule and a mismatch are said inside the dialog; a protected link is `#p1=` on the public base with the link (not the password) copied, the address bar untouched and the password in no storage key, store or address; over the size cap is an inline message with no alert; without Web Crypto the choice is disabled and the plain link still works. Opening: nothing of the shared diagram is in the document or the accessibility tree before the password; a wrong password keeps the document and its revision, and the typed text stays selected; the right one loads once. A wrong password and a damaged link show the same text and write the same console line. A broken link shows a notice without a password field and loses its fragment; an unknown version keeps it. A modified session is asked before it is replaced. Escape cancels, and a reload does not bring the prompt back. Two fast submits derive one key. A link sealed in the test with node:crypto opens in the browser, and sealed content that is not a diagram is refused without a retry. The first-run Welcome card waits behind the prompt. Behind the storage gate, the gate names the waiting link and keeps its fragment, and a temporary session opens it and stores no document. One test each where that spec does not run: the phone viewport (the same dialog from the More sheet, 16px fields, the prompt inside the screen), the installed PWA offline, the portable file (a link made on file:// opens on the hosted build), and the production bundle. Two existing tests change with the product. The share dialog has a third control, so the focus-trap test walks Cancel, Create link, the box, Cancel. The release-note list gains 0.17.0. Not covered by a test: the guard that keeps an Enter which only commits an input-method composition from submitting; a dispatched key event cannot stand in for a real composition. `docs/specs/SEMANTICS-P.md` is loop-share-protected/1: the link shape, the fixed parameters, the password rule, the fragment grammar, creating, the opening order, the error table, what it does not claim, the constants and a known-answer vector. `docs/specs/SEMANTICS-U-ERRATA.md` records what a plain link really carries (saved frames and imported spreadsheet records, beyond what the frozen text names) without rewriting the frozen document. --- README.md | 4 +- docs/specs/SEMANTICS-P.md | 273 ++++++++++++++++++ docs/specs/SEMANTICS-U-ERRATA.md | 39 +++ e2e/confirm-dialog.spec.ts | 6 +- e2e/dist.spec.ts | 52 ++++ e2e/mobile.spec.ts | 41 +++ e2e/portable-file.spec.ts | 37 +++ e2e/pwa.spec.ts | 48 ++++ e2e/share-protected.spec.ts | 470 +++++++++++++++++++++++++++++++ e2e/whats-new.spec.ts | 2 +- src/model/shareProtected.ts | 2 +- 11 files changed, 969 insertions(+), 5 deletions(-) create mode 100644 docs/specs/SEMANTICS-P.md create mode 100644 docs/specs/SEMANTICS-U-ERRATA.md create mode 100644 e2e/share-protected.spec.ts diff --git a/README.md b/README.md index 31f15371..04e03bcf 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ comparison. plus file-based project revisions & proposals for asynchronous collaboration — no accounts, no server - **Runs anywhere** — an installable offline PWA, a portable single-file - build, shareable links, and a UI translated into + build, shareable links (optionally protected with a password), and a UI translated into [18 languages](#languages) ## Languages @@ -126,7 +126,7 @@ new spec id, never an edit to a frozen one. - **Engine & simulation** — [`SEMANTICS.md`](docs/specs/SEMANTICS.md), [`SEMANTICS-B1.md`](docs/specs/SEMANTICS-B1.md) (seeded RNG), [`SEMANTICS-B2.md`](docs/specs/SEMANTICS-B2.md) (Monte Carlo) - **State connections** — [`SEMANTICS-S4.md`](docs/specs/SEMANTICS-S4.md) (`trigger` / `activator` / `label`; the latest of a sequential S1→S4 series, each frozen on its own) - **Model language & expressions** — [`SEMANTICS-X.md`](docs/specs/SEMANTICS-X.md), [`SEMANTICS-M2.md`](docs/specs/SEMANTICS-M2.md) (the latest of a sequential M1→M2 series) -- **File formats & revisions** — [`SEMANTICS-W.md`](docs/specs/SEMANTICS-W.md) (Workspace), [`SEMANTICS-U.md`](docs/specs/SEMANTICS-U.md) (Share links), [`SEMANTICS-R8.md`](docs/specs/SEMANTICS-R8.md) (revision projection/diff/Apply — the latest of a sequential R1→R8 series) +- **File formats & revisions** — [`SEMANTICS-W.md`](docs/specs/SEMANTICS-W.md) (Workspace), [`SEMANTICS-U.md`](docs/specs/SEMANTICS-U.md) (Share links, with [errata](docs/specs/SEMANTICS-U-ERRATA.md)), [`SEMANTICS-P.md`](docs/specs/SEMANTICS-P.md) (password-protected share links), [`SEMANTICS-R8.md`](docs/specs/SEMANTICS-R8.md) (revision projection/diff/Apply — the latest of a sequential R1→R8 series) **Project revisions & proposals** — a worked, file-based walkthrough of the create → propose → review → apply flow lives in diff --git a/docs/specs/SEMANTICS-P.md b/docs/specs/SEMANTICS-P.md new file mode 100644 index 00000000..97e3811c --- /dev/null +++ b/docs/specs/SEMANTICS-P.md @@ -0,0 +1,273 @@ +# Protected share link + +``` +Spec ID: loop-share-protected/1 +Status: Frozen on release (v0.17.0) +``` + +Defines the optional password protection of a share link: _what a protected +link is_, _how it is sealed_, and _how it is opened_. + +It is layered on `loop-share/1` ([`SEMANTICS-U.md`](SEMANTICS-U.md)) and changes +nothing in it. A plain `g1` link, its codec, its caps and its opening order are +exactly what that document says. A protected link carries the same bytes a +plain link carries, sealed. A behavioural change after the freeze is a new spec +id with a new fragment key (`p2`), never an edit here. + +What `loop-share/1` really carries is recorded in +[`SEMANTICS-U-ERRATA.md`](SEMANTICS-U-ERRATA.md); this document relies on that +corrected statement. + +--- + +## P0. Scope + +In scope: + +- a second kind of share link, chosen explicitly when a link is created; +- the sealed transport: key derivation, authenticated encryption, byte layout; +- the opening flow: structure check, password prompt, errors. + +Out of scope: + +- any server: accounts, password reset, link expiry, revocation; +- encrypting what is kept in browser storage; +- hiding the length of the ciphertext, or that a link exists. + +## P1. Link shape + +``` + "#p1=" payload +payload = base64url( salt[16] | iv[12] | ciphertext | tag[16] ) +``` + +- The public base is the fixed address of `loop-share/1` §U1.1, never `location`. +- `p` = protected, `1` = payload format. The prefix is the protocol header; + there is **no header byte** inside the payload. +- base64url is the strict, unpadded alphabet of §U1.4. +- The overhead over the sealed bytes is 44 bytes (salt, IV, tag). + +## P2. Method and fixed parameters + +| item | value | +|---|---| +| plaintext | the zlib-wrapped DEFLATE (RFC 1950) of the UTF-8 Graph JSON: exactly the bytes a `g1` link base64url-encodes | +| key derivation | PBKDF2-HMAC-SHA-256, **600,000** iterations, 16-byte random salt | +| key input | the UTF-8 bytes of the password after NFC normalisation | +| cipher | AES-256-GCM, 12-byte random IV, 128-bit authentication tag | +| additional authenticated data | the UTF-8 bytes of `loop-share-protected/1` (22 bytes) | +| key handling | a non-extractable `CryptoKey`; usage `encrypt` only when sealing, `decrypt` only when opening | +| randomness | `crypto.getRandomValues`, a fresh salt and IV for every link | +| implementation | Web Crypto only; no cryptographic code is written or bundled | + +**Nothing in a link selects a parameter.** The iteration count, the algorithms +and the lengths are fixed by this format and are never read from the link: a +count read from a link could be made large enough to stall a device. Changing +any of them needs a new fragment key. + +The additional authenticated data binds the format identifier into the +authentication, so a payload re-labelled as another version or algorithm does +not open. + +Compression comes before encryption because ciphertext does not compress. The +decompression cap of §U3.2 runs after decryption, unchanged. + +The count was chosen from measurements on phones: 85 ms on an iPhone 15 Pro +(149 to 151 ms in Low Power Mode) and 101 ms on a mid-range Android phone +(119 ms in battery saver), with no frame gap above 50 ms. It matches the current +OWASP figure for PBKDF2-HMAC-SHA-256. There is no per-device calibration: the +device that creates a link is not the device that opens it. + +## P3. Password + +- Length is counted in **code points after NFC normalisation**: 12 to 128 when + creating, 1 to 128 when opening. +- Leading and trailing whitespace is part of the password. Nothing is trimmed. +- Creating has a second field for confirmation and one show / hide control. + Pasting and password managers are not blocked. +- Creating uses `autocomplete="new-password"`, opening + `autocomplete="current-password"`. What a browser or a password manager offers + to save or fill is theirs to decide and is not controlled here. +- **Loop Studio does not store or transmit the password or the key.** The UI + says this sentence and not "not stored anywhere". +- The password is never put in the URL, in browser storage, in a store, on the + clipboard, or in a log. It exists in the input element and, for the duration + of one call, as an argument. + +## P4. The fragment + +`classifyFragment` (one classifier for both kinds of link): + +| fragment | kind | handling | +|---|---|---| +| `p1=` | protected | §P6 | +| `p1`, `p1x…` (starts `p1`, is not `p1=…`) | a broken protected link | visible notice, fragment removed, no password asked | +| `p=…`, n ≠ 1 | a protected link from a newer version | visible notice, **fragment kept**, so the same address opens after the app updates | +| `g…` | `loop-share/1` | unchanged | +| anything else | foreign | left untouched | + +A build that predates this document classifies every `p…` fragment as foreign +and leaves it in the address bar (measured on the shipped v0.16.0 build), so it +never consumes a protected link. + +As soon as a payload has passed the structure check of §P6 and its bytes are in +memory, the fragment is removed from the address bar. During a cancel, a failure +and a retry the sealed bytes are held in memory only. After a reload the link +has to be opened again. + +A link is consumed at boot. A navigation that changes only the fragment of an +already open document does not open it, as with a plain link. + +## P5. Creating + +- The plain link is the default and behaves as `loop-share/1` says. Protection + is an explicit choice in the same dialog. +- The dialog tells the sender to send the password some other way than the link, + that a lost password cannot be recovered, and that the link is only as strong + as its password. +- The outbound cap applies to the protected payload exactly as to a plain one: + `SHARE_MAX_BYTES` (8 KiB) characters after `#p1=`. Over the cap is a hard + reject shown inside the dialog, never a truncation. A document can therefore + fit as a plain link and not as a protected one; the window is 59 characters. +- While the key is derived the dialog shows a status line and accepts no second + submit. +- The link is what is copied. The address bar does not change. +- One shared action and one shared dialog serve desktop and the phone layout. + +## P6. Opening + +Fixed order, after the storage gate of issue #297 has let the app start: + +1. structure check +2. sealed bytes copied to memory, fragment removed +3. password prompt +4. key derivation +5. decrypt and authenticate +6. bounded inflate (§U3.2) +7. parse and validate +8. replace confirmation (§U5.4) +9. exactly one load (§U5.5) +10. the first-run Welcome card, only after all of the above + +**Structure check**, before any password is asked for: the payload is 60 to +8,192 characters of strict base64url and decodes to more than salt, IV and tag. +60 characters is salt + IV + tag + one byte. + +**Before a correct password nothing from the shared document is drawn**, and +nothing derived from the ciphertext (a size, a node count) is shown. There is no +plaintext to show. + +The prompt says whether the opened document will be kept in this browser (a +personal session) or not (a temporary session); the storage rule itself is +issue #297's. + +One derivation runs at a time. Cancel leaves the current document and any run in +progress untouched; a derivation still running when Cancel is pressed is ignored +when it finishes. A press on the backdrop does not dismiss the prompt. + +After a successful open the rules of `loop-share/1` apply: the replace +confirmation unless the session is the untouched sample, one load, no auto-run. + +## P7. Without Web Crypto + +Web Crypto exists only in a secure context. There is no fallback. + +- Creating: the choice is disabled and the reason is shown. The plain link works. +- Opening a well-formed protected link: a notice says a current browser and + HTTPS are required, and the fragment is **not** removed, so the link can be + opened elsewhere. +- A broken link is reported as broken (§P4) here too; that check needs no + cryptography. + +## P8. Errors + +| cause | password asked | shown | console | fragment | +|---|---|---|---|---| +| broken structure | no | notice: damaged or incomplete | one fixed line | removed | +| newer version (`p`) | no | notice: update the app | one fixed line | kept | +| no Web Crypto | no | notice: requirement | one fixed line | kept | +| wrong password, failed authentication, damaged ciphertext | again, without limit | **one** message | **one** fixed line | already removed | +| authenticated, but the content is not a diagram | no retry | a different notice | one fixed line | already removed | + +A wrong password, a changed salt, IV, ciphertext or tag, and a truncated link +are **indistinguishable**: the same text, the same console line, the same +state. The browser reports data shorter than the tag with a different message +than the other failures, so whatever the browser reports is dropped inside the +sealed transport and never shown or logged. + +There is no retry limit and no lock-out. Offline guessing cannot be prevented, +and the dialog says so when a link is created. + +Every failure leaves the current document and any run in progress untouched. + +## P9. What it does not claim + +- It cannot hide the length of the ciphertext, which follows the size and the + compressibility of the document, or the fact that a link was used. +- It is only as strong as the password. The ciphertext, the salt and the IV stay + in browser history, on the clipboard and in chat logs, so guesses can be tried + offline with no limit on attempts. +- It is not sign-in and not access control. Anyone with the link and the + password can open, edit and re-share the document as a plain link. +- A JavaScript string cannot be erased. The claim is that the password is not + stored and not transmitted, not that it leaves memory at a known moment. + +## P10. Compatibility + +| reader | link | result | +|---|---|---| +| a build before `loop-share-protected/1` | `p1=…` | foreign fragment, left in the address bar; opens after the update | +| this build | `p1=…` | §P6 | +| this build | `p=…`, n ≠ 1 | notice, fragment kept | +| this build | any `g…` fragment | `loop-share/1`, unchanged | +| web, installed PWA, portable `file://` | each other's `p1=` link | must open (a link always targets the public base) | + +The graph wire format is unchanged (`loop-studio/graph` `version: 1`). + +## P11. Constants + +| name | value | +|---|---| +| protected prefix | `"p1="` | +| format identifier (AAD) | `loop-share-protected/1` | +| iterations | `600000` | +| salt / IV / tag | 16 / 12 / 16 bytes | +| payload bounds | 60 to `SHARE_MAX_BYTES` (8,192) characters | +| password, creating | 12 to 128 code points after NFC | +| password, opening | 1 to 128 code points after NFC | +| decoded cap | `SHARE_MAX_DECODED_BYTES` (1 MiB), from `loop-share/1` | + +`npm run check:share-crypto` keeps the key and cipher calls in +`src/model/shareProtected.ts`, the iteration count and the identifier written +once, and `package.json` free of cryptography libraries. + +## P12. Acceptance vectors + +The unit tests carry an independent implementation (node:crypto and node:zlib) +and compare in both directions: each side opens what the other sealed. Product +code has no way to be handed a salt or an IV. + +Known answer, made with the independent implementation: + +``` +password correct horse battery staple +salt 000102030405060708090a0b0c0d0e0f +iv a0a1a2a3a4a5a6a7a8a9aaab +plaintext zlib( {"schema":"loop-studio/graph","version":1,"nodes":[],"edges":[]} ) +payload AAECAwQFBgcICQoLDA0OD6ChoqOkpaanqKmqq9PyGNZRewug-mdGAFZfxHzXjsIb0FFAeYydCqMS_nEtA-vbA5mP6DAdST51g7zuZ6-6wrsnUiGAb8oALBa6Bcuaj-G48CwYYw5iJ6d5PPVB2iAd +``` + +Mutations of the independent side that must not open: another identifier as +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. + +## P13. Not verified + +- What a browser or a password manager offers to save or fill. +- Web Crypto on `file://` outside Chromium. +- A real screen reader. +- Key derivation time on a low-end phone. diff --git a/docs/specs/SEMANTICS-U-ERRATA.md b/docs/specs/SEMANTICS-U-ERRATA.md new file mode 100644 index 00000000..feabab18 --- /dev/null +++ b/docs/specs/SEMANTICS-U-ERRATA.md @@ -0,0 +1,39 @@ +# Errata to `loop-share/1` + +[`SEMANTICS-U.md`](SEMANTICS-U.md) is frozen and is not rewritten. This file +records where its text and the shipped behaviour differ, so that a document +built on it ([`SEMANTICS-P.md`](SEMANTICS-P.md)) can say what a link really +carries. Nothing here changes behaviour. + +## E1. §U2 — what a link carries + +**The text says** a link carries the whole `GraphDoc` — `nodes`, `edges`, +`recommendedRunConfig` — and "nothing else" (§U2, and row U2 of the invariants +table). + +**The shipped behaviour** is that a link carries what `Graph JSON` export +writes, and that file has grown since the freeze. Besides the nodes, the edges +and the recommended run configuration, a link carries: + +- the **saved frames** of the document (their ids, titles, rectangles and + colours), since saved frames became part of the graph document; +- the **imported spreadsheet records** (`dataImports`): the source tables a + data import kept in the document, including their cell values. + +Opening a link replaces both in the receiving document: a link without frames or +tables clears the current ones, a link with them restores exactly them. + +The sentence that still holds is the principle of §U2: a link is a _document_, +not a session. It does not carry Monte Carlo results, a simulation snapshot, a +seed, the canvas viewport, undo history, selection, the theme, the language or +any other preference. + +**Why it matters.** The disclosure shown before a link is created names labels, +imported spreadsheet values and saved frames for this reason, and a protected +link (`loop-share-protected/1`) seals all of it. + +## E2. §U7 — dialogs on load + +§U7 says there is no dialog on load except the replace confirmation. That +remains true of a `g1` link. A `p1` link is not a `loop-share/1` link: its +password prompt and its notices belong to `loop-share-protected/1`. diff --git a/e2e/confirm-dialog.spec.ts b/e2e/confirm-dialog.spec.ts index 827be0e5..535bfe12 100644 --- a/e2e/confirm-dialog.spec.ts +++ b/e2e/confirm-dialog.spec.ts @@ -102,10 +102,14 @@ test('focus is trapped, initial focus is Cancel, and it returns to the trigger o // initial focus on Cancel — so a stray Enter never fires the confirm await expect(dlg(page).getByRole('button', { name: /^cancel$/i })).toBeFocused() - // Tab cycles inside the dialog only (2 buttons → wraps) + // Tab cycles inside the dialog only. Since issue #300 the share dialog has a + // third control, the `Protect with a password` box, which comes first in the + // document: Cancel → Create link → (wraps) the box → Cancel. await page.keyboard.press('Tab') await expect(dlg(page).getByRole('button', { name: /create link/i })).toBeFocused() await page.keyboard.press('Tab') + await expect(dlg(page).locator('[data-share-protect="option"]')).toBeFocused() + await page.keyboard.press('Tab') await expect(dlg(page).getByRole('button', { name: /^cancel$/i })).toBeFocused() await page.keyboard.press('Escape') diff --git a/e2e/dist.spec.ts b/e2e/dist.spec.ts index 08c5b6e8..622d8a0a 100644 --- a/e2e/dist.spec.ts +++ b/e2e/dist.spec.ts @@ -623,3 +623,55 @@ test.describe('production build — the stored theme is on before the first pain expect(kinds.filter((k) => k === 'dark').length).toBeGreaterThan(0) }) }) + +// issue #300 - a protected share link in the PRODUCTION bundle: no dev bridge, +// the minified sealed transport, the lazy app chunk +test.describe('production build: a password-protected share link', () => { + test('is created and opened; a wrong password loads nothing; no request leaves the origin', async ({ page, context }) => { + const password = 'dist e2e password 19' // never a real one + await page.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => undefined } }) + }) + const { bad } = await openProd(page) + await page.locator('.toolbar__palette .chip--drain').click() + const nodes = await page.locator('.react-flow__node').count() + await page.locator('.toolbar__actions button', { hasText: /^Share$/ }).click() + const dlg = page.locator('.mcdlg--share[data-share-create]') + await dlg.locator('[data-share-protect="option"]').check() + await dlg.locator('[data-share-protect="password"]').fill(password) + await dlg.locator('[data-share-protect="confirm"]').fill(password) + await dlg.getByRole('button', { name: 'Create link' }).click() + const url = await page.locator('.share-pop__url').inputValue() + expect(url).toMatch(/^https:\/\/cozy-loop-studio\.pages\.dev\/#p1=[A-Za-z0-9_-]+$/) + + // one more node after the link was made: what this browser stores now differs + // from what the link carries, so the count at the end can only come from the link + await page.keyboard.press('Escape') + await page.locator('.toolbar__palette .chip--drain').click() + await page.waitForFunction( + (n) => (JSON.parse(localStorage.getItem('loop-studio:graph:v1') ?? '{}').nodes ?? []).length === n, + nodes + 1, + ) + + const p2 = await context.newPage() + const warnings: string[] = [] + p2.on('console', (m) => { + if (m.type() === 'warning' && m.text().startsWith('Loop Studio:')) warnings.push(m.text()) + }) + p2.on('dialog', (d) => void d.accept()) // the stored diagram is not the untouched sample + await p2.goto(`/#p1=${url.split('#p1=')[1]}`) + const prompt = p2.locator('[data-protected-open="prompt"]') + await expect(prompt).toBeVisible() + await expect(p2.locator('.react-flow__node')).toHaveCount(nodes + 1) + expect(await p2.evaluate(() => location.hash)).toBe('') + await prompt.locator('[data-protected-open="password"]').fill('not the password') + await prompt.getByRole('button', { name: 'Open' }).click() + await expect(prompt.locator('[data-protected-open="error"]')).toBeVisible() + expect(warnings).toEqual(['Loop Studio: a protected share link was not opened; the password is wrong or the link is damaged.']) + await prompt.locator('[data-protected-open="password"]').fill(password) + await prompt.getByRole('button', { name: 'Open' }).click() + await expect(prompt).toHaveCount(0) + await expect(p2.locator('.react-flow__node')).toHaveCount(nodes) + expect(bad).toEqual([]) + }) +}) diff --git a/e2e/mobile.spec.ts b/e2e/mobile.spec.ts index 69a97fc4..4cb3c8d7 100644 --- a/e2e/mobile.spec.ts +++ b/e2e/mobile.spec.ts @@ -388,6 +388,47 @@ test.describe('mobile view/run — Slice 2 chrome', () => { await expect(field).toHaveValue(/^https:\/\/cozy-loop-studio\.pages\.dev\/#g1=/) }) + // issue #300 - the same dialog and the same shared action as desktop + test('Share from the More menu can protect the link with a password; the dialog and the opening prompt fit the screen', async ({ page }) => { + const password = 'phone e2e password 7' // never a real one + await loadDiagram(page) + await more(page).click() + await sheet(page, 'More').locator('.sheet__row', { hasText: 'Share link' }).click() + const dlg = page.locator('.mcdlg--share[data-share-create]') + await dlg.locator('[data-share-protect="option"]').check() + await dlg.locator('[data-share-protect="password"]').fill(password) + await dlg.locator('[data-share-protect="confirm"]').fill(password) + expect(rectInside(await dlg.boundingBox(), PORTRAIT.width, PORTRAIT.height, 1)).toBe(true) + // under 16px, iOS zooms the page when the field takes focus + expect(await dlg.locator('[data-share-protect="password"]').evaluate((el) => getComputedStyle(el).fontSize)).toBe('16px') + await dlg.getByRole('button', { name: 'Create link' }).click() + + const field = page.locator('.sheet .share-pop__url') + await expect(field).toHaveValue(/^https:\/\/cozy-loop-studio\.pages\.dev\/#p1=[A-Za-z0-9_-]+$/) + await expect(page.locator('.sheet [data-share-protected="note"]')).toBeVisible() + const nodes = await page.locator('.react-flow__node').count() + const hash = (await field.inputValue()).split('#')[1] + + // what this browser stores is now something else, so the count below can + // only come from the link + await resetAll(page) + await page.evaluate(() => (window as unknown as { __loop: { autosave: { flush: () => void } } }).__loop.autosave.flush()) + expect(await page.locator('.react-flow__node').count()).not.toBe(nodes) + + // a link is consumed at boot: a real load, on the local server + await page.goto('about:blank') + await page.goto('/#' + hash) + const prompt = page.locator('[data-protected-open="prompt"]') + await expect(prompt).toBeVisible() + expect(rectInside(await prompt.boundingBox(), PORTRAIT.width, PORTRAIT.height, 1)).toBe(true) + expect(await page.evaluate(() => location.hash)).toBe('') + await prompt.locator('[data-protected-open="password"]').fill(password) + page.once('dialog', (d) => void d.accept()) // the stored diagram is not the untouched sample + await prompt.getByRole('button', { name: 'Open' }).click() + await expect(prompt).toHaveCount(0) + await expect(page.locator('.react-flow__node')).toHaveCount(nodes) + }) + test('PWA update bar + an open sheet: Close, Update and Play are each visible and clickable', async ({ page }) => { await loadDiagram(page) // dev has no service worker — poke the store so `.pwa-update` renders diff --git a/e2e/portable-file.spec.ts b/e2e/portable-file.spec.ts index b15e4d8e..30d9ac14 100644 --- a/e2e/portable-file.spec.ts +++ b/e2e/portable-file.spec.ts @@ -484,4 +484,41 @@ test.describe('portable file://', () => { await openPayloadOnHttp(browser, url.split('#g1=')[1]) }) + + // issue #300 - Web Crypto is there on file:// (a secure context in Chromium), + // so the portable file makes a protected link the hosted build opens + test('Share on file:// can protect the link with a password, and the hosted build opens it', async ({ browser }) => { + test.setTimeout(60_000) + const password = 'portable e2e password 5' // never a real one + const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }) + await seedPersonalBrowser(ctx) + const page = await ctx.newPage() + await page.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => undefined } }) + }) + await openPortable(page) // imports risky-factory (18 nodes) + await page.locator('.toolbar__actions button', { hasText: /^Share$/ }).click() + const dlg = page.locator('.mcdlg--share[data-share-create]') + await expect(dlg.locator('[data-share-protect="option"]')).toBeEnabled() + await dlg.locator('[data-share-protect="option"]').check() + await dlg.locator('[data-share-protect="password"]').fill(password) + await dlg.locator('[data-share-protect="confirm"]').fill(password) + await dlg.getByRole('button', { name: /create link/i }).click() + const url = await page.locator('.share-pop__url').inputValue() + await ctx.close() + expect(url).toMatch(/^https:\/\/cozy-loop-studio\.pages\.dev\/#p1=[A-Za-z0-9_-]+$/) + + const hosted = await browser.newContext({ viewport: { width: 1280, height: 800 } }) + await seedPersonalBrowser(hosted) + const p2 = await hosted.newPage() + await p2.goto(`${HTTP}/#p1=${url.split('#p1=')[1]}`) + const prompt = p2.locator('[data-protected-open="prompt"]') + await expect(prompt).toBeVisible() + await prompt.locator('[data-protected-open="password"]').fill(password) + await prompt.getByRole('button', { name: 'Open' }).click() + await expect(prompt).toHaveCount(0) + await expect(p2.locator('.react-flow__node')).toHaveCount(18) + expect(await p2.evaluate(() => location.hash)).toBe('') + await hosted.close() + }) }) diff --git a/e2e/pwa.spec.ts b/e2e/pwa.spec.ts index ad0054d5..40d1fad6 100644 --- a/e2e/pwa.spec.ts +++ b/e2e/pwa.spec.ts @@ -212,6 +212,54 @@ test('offline: a #g1= share link opens from cache and strips the fragment', asyn await context.setOffline(false) }) +// issue #300 - sealing and opening a protected link need no network at all +test('offline: a #p1= protected link asks for its password and opens from cache', async ({ page, context }) => { + const password = 'pwa e2e password 31' // never a real one + await page.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => undefined } }) + }) + await setGen(page, 'a') + await installAndControl(page) + + await page.locator('.toolbar__palette .chip--drain').click() + const distinct = await nodeCount(page) + await context.setOffline(true) // the link is made offline too + await page.locator('.toolbar__actions button', { hasText: /^Share$/ }).click() + const dlg = page.locator('.mcdlg--share[data-share-create]') + await dlg.locator('[data-share-protect="option"]').check() + await dlg.locator('[data-share-protect="password"]').fill(password) + await dlg.locator('[data-share-protect="confirm"]').fill(password) + await dlg.getByRole('button', { name: /create link/i }).click() + const url = await page.locator('.share-pop__url').inputValue() + const payload = url.split('#p1=')[1] + expect(payload).toMatch(/^[A-Za-z0-9_-]+$/) + + // one more node after the link was made: what this browser stores now differs + // from what the link carries, so the count at the end can only come from the link + await page.keyboard.press('Escape') + await page.locator('.toolbar__palette .chip--drain').click() + await page.waitForFunction( + (n) => (JSON.parse(localStorage.getItem('loop-studio:graph:v1') ?? '{}').nodes ?? []).length === n, + distinct + 1, + ) + + const p2 = await context.newPage() + p2.once('dialog', (d) => { + expect(d.message()).toMatch(/replaced/i) + void d.accept() + }) + await p2.goto(`/#p1=${payload}`) + const prompt = p2.locator('[data-protected-open="prompt"]') + await expect(prompt).toBeVisible() + await expect(p2.locator('.react-flow__node')).toHaveCount(distinct + 1) + expect(await p2.evaluate(() => location.hash)).toBe('') + await prompt.locator('[data-protected-open="password"]').fill(password) + await prompt.getByRole('button', { name: 'Open' }).click() + await expect(prompt).toHaveCount(0) + await expect(p2.locator('.react-flow__node')).toHaveCount(distinct) + await context.setOffline(false) +}) + // ── registration gate (real browser, not just the unit test) ──────────── // issue #302 - the installed app opens in the stored theme at its first frame, diff --git a/e2e/share-protected.spec.ts b/e2e/share-protected.spec.ts new file mode 100644 index 00000000..62e9c9c1 --- /dev/null +++ b/e2e/share-protected.spec.ts @@ -0,0 +1,470 @@ +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' + +// 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 +// `#p1=` link, and the prompt it opens with. +// +// The unit tests pin the sealed transport against an independent +// implementation. This spec repeats that once in a real browser (a link sealed +// here with node:crypto opens in the app), and otherwise checks what only a +// browser can show: what is on screen before the password, what stays in the +// address bar, what reaches the clipboard and storage, and the order of the +// dialogs. + +type Bridge = { + __loop: Record any } & Record> & { + autosave: { flush: () => void } + } +} + +const PASSWORD = 'e2e protected link 42' // never a real one +const SHARE_BASE = 'https://cozy-loop-studio.pages.dev/' +const WORK_KEY = 'loop-studio:graph:v1' + +/** loop-share-protected/1, written with node:crypto and node:zlib only */ +function nodeSeal(text: string, password: string, plain?: Buffer): string { + const salt = randomBytes(16) + const iv = randomBytes(12) + const key = pbkdf2Sync(Buffer.from(password.normalize('NFC'), 'utf8'), salt, 600000, 32, 'sha256') + const c = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) + c.setAAD(Buffer.from('loop-share-protected/1', 'utf8')) + const ct = Buffer.concat([c.update(plain ?? deflateSync(Buffer.from(text, 'utf8'))), c.final()]) + return Buffer.concat([salt, iv, ct, c.getAuthTag()]).toString('base64url') +} + +async function stubClipboard(target: Page | BrowserContext): Promise { + await target.addInitScript(() => { + ;(window as any).__clipWrites = [] + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: async (t: string) => void (window as any).__clipWrites.push(t) }, + }) + }) +} +const clipWrites = (page: Page) => page.evaluate(() => (window as any).__clipWrites as string[]) + +/** count key derivations, from before any page script runs */ +async function countDerivations(target: Page | BrowserContext): Promise { + await target.addInitScript(() => { + ;(window as any).__derives = 0 + if (!crypto.subtle) return // `about:blank` is not a secure context + const real = crypto.subtle.deriveKey.bind(crypto.subtle) + crypto.subtle.deriveKey = ((...a: Parameters) => { + ;(window as any).__derives++ + return real(...a) + }) as SubtleCrypto['deriveKey'] + }) +} + +/** a real cross-document load (a hash-only `goto` is same-document, and a link + * is consumed at boot only) */ +async function freshGoto(page: Page, url: string): Promise { + await page.goto('about:blank') + await page.goto(url) + await expect(page.locator('.toolbar')).toBeVisible() + await page.waitForFunction(() => Boolean((window as any).__loop)) +} + +const shareBtn = (page: Page) => page.locator('.toolbar__actions button', { hasText: /^Share$/ }) +const createDialog = (page: Page) => page.locator('.mcdlg--share[data-share-create]') +const field = (page: Page, name: string) => page.locator(`[data-share-protect="${name}"]`) +const prompt = (page: Page) => page.locator('[data-protected-open="prompt"]') +const promptPart = (page: Page, name: string) => page.locator(`[data-protected-open="${name}"]`) +const notice = (page: Page) => page.locator('[data-protected-open="notice"]') +const hashOf = (page: Page) => page.evaluate(() => location.hash) + +/** Source "α ⚙" ─3→ Pool "β 보물" - labels that exist nowhere else in the app */ +async function seedGraph(page: Page): Promise { + await page.evaluate(() => { + const g = (window as unknown as Bridge).__loop.graph.getState() + g.newGraph() + g.addNodeAt('source', { x: 0, y: 0 }) + g.addNodeAt('pool', { x: 240, y: 0 }) + const [s, p] = (window as unknown as Bridge).__loop.graph.getState().nodes + const gs = (window as unknown as Bridge).__loop.graph.getState() + gs.updateNodeData(s.id, { label: 'α ⚙' }) + gs.updateNodeData(p.id, { label: 'β 보물' }) + gs.onConnect({ source: s.id, target: p.id, sourceHandle: 'out', targetHandle: 'in' }) + }) +} +const labelsOf = (page: Page) => + page.evaluate(() => (window as unknown as Bridge).__loop.graph.getState().nodes.map((n: any) => n.data.label).sort()) +const revOf = (page: Page) => page.evaluate(() => (window as unknown as Bridge).__loop.graph.getState().simulationRev as number) + +/** Share → tick the box → fill both fields → Create link; returns the link */ +async function createProtected(page: Page, password = PASSWORD): Promise { + await shareBtn(page).click() + await expect(createDialog(page)).toBeVisible() + 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() + const url = page.locator('.share-pop__url') + await expect(url).toBeVisible() + return url.inputValue() +} + +/** make a protected link of the seeded graph in a profile of its own */ +async function linkFromAnotherProfile(browser: Browser): Promise<{ hash: string; json: string }> { + const ctx = await browser.newContext() + await seedPersonalBrowser(ctx) + await stubClipboard(ctx) + const page = await ctx.newPage() + await openApp(page) + await seedGraph(page) + const json = await page.evaluate(() => (window as unknown as Bridge).__loop.graph.getState().exportJSON() as string) + const url = await createProtected(page) + await ctx.close() + return { hash: url.slice(url.indexOf('#')), json } +} + +/** forget what this profile stored, so the next boot is the untouched sample */ +async function forgetStoredWork(page: Page): Promise { + await page.evaluate(() => { + ;(window as unknown as Bridge).__loop.autosave.flush() + localStorage.clear() + }) +} + +async function typeAndOpen(page: Page, password: string): Promise { + await promptPart(page, 'password').fill(password) + await prompt(page).getByRole('button', { name: 'Open' }).click() +} + +test.describe('creating a link', () => { + test.beforeEach(async ({ page }) => { + await stubClipboard(page) + await openApp(page) + await resetAll(page) + }) + + test('the plain link is the default: the box is unticked, focus starts on Cancel, and the link is a g1 link', async ({ page }) => { + await shareBtn(page).click() + await expect(createDialog(page)).toHaveAttribute('data-share-create', 'plain') + await expect(field(page, 'option')).not.toBeChecked() + await expect(createDialog(page).getByRole('button', { name: 'Cancel' })).toBeFocused() + await expect(field(page, 'password')).toHaveCount(0) + await createDialog(page).getByRole('button', { name: 'Create link' }).click() + const url = await page.locator('.share-pop__url').inputValue() + expect(url.startsWith(SHARE_BASE + '#g1=')).toBe(true) + await expect(page.locator('[data-share-protected="note"]')).toHaveCount(0) + }) + + test('the rule and a mismatch are said inside the dialog; nothing is created, and closing forgets everything', async ({ page }) => { + await shareBtn(page).click() + await field(page, 'option').check() + await expect(field(page, 'password')).toBeFocused() + await expect(field(page, 'password')).toHaveAttribute('autocomplete', 'new-password') + await expect(field(page, 'confirm')).toHaveAttribute('autocomplete', 'new-password') + + await field(page, 'password').fill('eleven char') + await field(page, 'confirm').fill('eleven char') + await createDialog(page).getByRole('button', { name: 'Create link' }).click() + await expect(field(page, 'problem')).toHaveText('The password needs at least 12 characters.') + + 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('The two passwords are not the same.') + await expect(field(page, 'problem')).toHaveAttribute('role', 'alert') + + expect(await clipWrites(page)).toEqual([]) + await expect(page.locator('.share-pop')).toHaveCount(0) + + await createDialog(page).getByRole('button', { name: 'Cancel' }).click() + await expect(createDialog(page)).toHaveCount(0) + await shareBtn(page).click() + await expect(field(page, 'option')).not.toBeChecked() + await expect(field(page, 'problem')).toHaveCount(0) + }) + + test('a protected link: `#p1=` on the public base, the LINK copied, the address bar untouched, the password nowhere', async ({ page }) => { + const before = await page.evaluate(() => location.href) + const url = await createProtected(page) + expect(url).toMatch(/^https:\/\/cozy-loop-studio\.pages\.dev\/#p1=[A-Za-z0-9_-]{60,8192}$/) + expect(await clipWrites(page)).toEqual([url]) + await expect(page.locator('[data-share-protected="note"]')).toHaveText('This link is protected with a password. Send the password separately.') + expect(await page.evaluate(() => location.href)).toBe(before) + await expect(createDialog(page)).toHaveCount(0) + + const leaks = await page.evaluate((pw) => { + const hit: string[] = [] + for (const store of [localStorage, sessionStorage]) { + for (let i = 0; i < store.length; i++) { + const k = store.key(i)! + if ((store.getItem(k) ?? '').includes(pw) || k.includes(pw)) hit.push('storage:' + k) + } + } + if (location.href.includes(pw)) hit.push('location') + if (document.cookie.includes(pw)) hit.push('cookie') + for (const [name, s] of Object.entries((window as unknown as Bridge).__loop)) { + if (typeof (s as { getState?: unknown }).getState !== 'function') continue + let text = '' + try { + text = JSON.stringify((s as { getState: () => unknown }).getState()) + } catch { + text = '' + } + if (text.includes(pw)) hit.push('store:' + name) + } + return hit + }, PASSWORD) + expect(leaks).toEqual([]) + expect(url).not.toContain(encodeURIComponent(PASSWORD)) + }) + + test('over the size cap while protecting: said inside the dialog, no alert, nothing copied', async ({ page }) => { + const alerts: string[] = [] + page.on('dialog', (d) => { + alerts.push(d.message()) + void d.dismiss() + }) + await page.evaluate(() => { + ;(window as unknown as { __shareMaxBytes: number }).__shareMaxBytes = 100 + }) + 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')).toContainText('too large for a share link') + await expect(createDialog(page)).toBeVisible() + expect(alerts).toEqual([]) + expect(await clipWrites(page)).toEqual([]) + await expect(page.locator('.share-pop')).toHaveCount(0) + }) +}) + +test.describe('creating without Web Crypto', () => { + test('the choice is disabled and says why; the plain link still works', async ({ browser }) => { + const ctx = await browser.newContext() + await seedPersonalBrowser(ctx) + await stubClipboard(ctx) + await ctx.addInitScript(() => Object.defineProperty(Crypto.prototype, 'subtle', { configurable: true, get: () => undefined })) + const page = await ctx.newPage() + await openApp(page) + await shareBtn(page).click() + await expect(field(page, 'option')).toBeDisabled() + await expect(field(page, 'unavailable')).toContainText('needs a current browser and a secure (HTTPS) address') + await createDialog(page).getByRole('button', { name: 'Create link' }).click() + expect((await page.locator('.share-pop__url').inputValue()).startsWith(SHARE_BASE + '#g1=')).toBe(true) + await ctx.close() + }) +}) + +test.describe('opening a link', () => { + let hash = '' + let json = '' + test.beforeAll(async ({ browser }) => { + ;({ hash, json } = await linkFromAnotherProfile(browser)) + }) + + test('nothing of the shared diagram exists before the password; a wrong one changes nothing; the right one loads it once', async ({ page }) => { + await freshGoto(page, '/' + hash) + await expect(prompt(page)).toBeVisible() + expect(await hashOf(page)).toBe('') // the fragment is gone as soon as the bytes are in memory + await expect(promptPart(page, 'password')).toBeFocused() + await expect(promptPart(page, 'password')).toHaveAttribute('autocomplete', 'current-password') + await expect(promptPart(page, 'storage-note')).toHaveText('Once opened, the diagram is kept in this browser like any other diagram.') + await expect(prompt(page).getByRole('button', { name: 'Open' })).toBeDisabled() + + // nothing from the link: not in the document's own data, not in the accessibility tree + const sample = await labelsOf(page) + expect(sample).not.toContain('α ⚙') + const tree = await page.locator('body').ariaSnapshot() + expect(tree).not.toContain('α ⚙') + expect(tree).not.toContain('β 보물') + await expect(prompt(page)).not.toContainText(/node|byte|KB/i) // and nothing derived from the ciphertext + + const rev = await revOf(page) + await typeAndOpen(page, 'not the password at all') + await expect(promptPart(page, 'error')).toHaveText('The password is wrong, or the link is damaged. Check the password and try again.') + await expect(promptPart(page, 'error')).toHaveAttribute('role', 'alert') + // what was typed is kept, selected, and focused + const input = await promptPart(page, 'password').evaluate((el: HTMLInputElement) => ({ + value: el.value, + selected: el.selectionStart === 0 && el.selectionEnd === el.value.length, + focused: document.activeElement === el, + })) + expect(input).toEqual({ value: 'not the password at all', selected: true, focused: true }) + expect(await labelsOf(page)).toEqual(sample) + expect(await revOf(page)).toBe(rev) + + await typeAndOpen(page, PASSWORD) + await expect(prompt(page)).toHaveCount(0) + expect(await labelsOf(page)).toEqual(['α ⚙', 'β 보물']) + expect(await revOf(page)).toBe(rev + 1) // exactly one load + }) + + test('a wrong password and a damaged link show the same text and write the same console line', async ({ page }) => { + const warnings: string[] = [] + page.on('console', (m) => { + if (m.type() === 'warning' && m.text().startsWith('Loop Studio:')) warnings.push(m.text()) + }) + await freshGoto(page, '/' + hash) + await typeAndOpen(page, 'not the password at all') + const wrongText = await promptPart(page, 'error').innerText() + const wrongLines = warnings.splice(0) + + // one character changed in the middle: still well-formed, no longer authentic + const i = 4 + 70 + const damaged = hash.slice(0, i) + (hash[i] === 'A' ? 'B' : 'A') + hash.slice(i + 1) + await freshGoto(page, '/' + damaged) + await typeAndOpen(page, PASSWORD) + const damagedText = await promptPart(page, 'error').innerText() + + expect(wrongLines).toHaveLength(1) + expect(warnings).toEqual(wrongLines) + expect(damagedText).toBe(wrongText) + await expect(prompt(page)).toBeVisible() // the same state: a prompt that can be answered again + }) + + test('a broken link shows a notice with no password field and loses its fragment; an unknown version keeps it', async ({ page }) => { + for (const broken of ['#p1=AAAA', '#p1', '#p1x', '#p1=' + 'A'.repeat(8193)]) { + await freshGoto(page, '/' + broken) + await expect(notice(page)).toHaveAttribute('data-notice', 'damaged') + await expect(notice(page)).toHaveAttribute('role', 'alertdialog') + await expect(promptPart(page, 'password')).toHaveCount(0) + expect(await hashOf(page), broken.slice(0, 12)).toBe('') + } + await freshGoto(page, '/#p2=AAAABBBB') + await expect(notice(page)).toHaveAttribute('data-notice', 'newer') + await expect(notice(page)).toContainText('newer version of Loop Studio') + expect(await hashOf(page)).toBe('#p2=AAAABBBB') + await notice(page).getByRole('button', { name: 'Close' }).click() + await expect(notice(page)).toHaveCount(0) + expect(await hashOf(page)).toBe('#p2=AAAABBBB') + }) + + test('a modified session is asked before it is replaced: Cancel keeps it, OK replaces it', async ({ page }) => { + await openApp(page) + await resetAll(page) + await page.evaluate(() => (window as unknown as Bridge).__loop.graph.getState().addNodeAt('drain', { x: 40, y: 40 })) + const mine = await labelsOf(page) + await page.evaluate(() => (window as unknown as Bridge).__loop.autosave.flush()) + + const asked: string[] = [] + let answer = false + page.on('dialog', (d) => { + asked.push(d.message()) + void (answer ? d.accept() : d.dismiss()) + }) + await freshGoto(page, '/' + hash) + await typeAndOpen(page, PASSWORD) + await expect(prompt(page)).toHaveCount(0) + expect(asked).toHaveLength(1) + expect(asked[0]).toContain('Open the shared diagram?') + expect(await labelsOf(page)).toEqual(mine) + + answer = true + await freshGoto(page, '/' + hash) + await typeAndOpen(page, PASSWORD) + await expect(prompt(page)).toHaveCount(0) + expect(asked).toHaveLength(2) + expect(await labelsOf(page)).toEqual(['α ⚙', 'β 보물']) + }) + + test('Escape cancels the prompt: the document is untouched and the link has to be opened again', async ({ page }) => { + await freshGoto(page, '/' + hash) + const sample = await labelsOf(page) + await expect(prompt(page)).toBeVisible() + await page.keyboard.press('Escape') + await expect(prompt(page)).toHaveCount(0) + expect(await labelsOf(page)).toEqual(sample) + await page.reload() + await expect(page.locator('.toolbar')).toBeVisible() + await expect(prompt(page)).toHaveCount(0) + }) + + test('two fast submits derive one key', async ({ page }) => { + await countDerivations(page) + await freshGoto(page, '/' + hash) + await promptPart(page, 'password').fill(PASSWORD) + await prompt(page).getByRole('button', { name: 'Open' }).dblclick() + await expect(prompt(page)).toHaveCount(0) + expect(await page.evaluate(() => (window as any).__derives)).toBe(1) + }) + + test('a link sealed by an independent implementation opens; sealed content that is not a diagram is refused without a retry', async ({ page }) => { + await freshGoto(page, '/#p1=' + nodeSeal(json, PASSWORD)) + await typeAndOpen(page, PASSWORD) + await expect(prompt(page)).toHaveCount(0) + expect(await labelsOf(page)).toEqual(['α ⚙', 'β 보물']) + + await forgetStoredWork(page) + await freshGoto(page, '/#p1=' + nodeSeal('this is sealed correctly and is not a diagram', PASSWORD)) + const sample = await labelsOf(page) // the untouched first-boot sample again + expect(sample).not.toContain('α ⚙') + await typeAndOpen(page, PASSWORD) + await expect(notice(page)).toHaveAttribute('data-notice', 'content') + await expect(promptPart(page, 'password')).toHaveCount(0) + expect(await labelsOf(page)).toEqual(sample) + }) + + test('without Web Crypto the link is not opened, the reason is shown, and the fragment stays', async ({ browser }) => { + const ctx = await browser.newContext() + await seedPersonalBrowser(ctx) + await ctx.addInitScript(() => Object.defineProperty(Crypto.prototype, 'subtle', { configurable: true, get: () => undefined })) + const page = await ctx.newPage() + await freshGoto(page, '/' + hash) + await expect(notice(page)).toHaveAttribute('data-notice', 'unavailable') + await expect(notice(page)).toContainText('needs a current browser and a secure (HTTPS) address') + 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) + // a remembered personal browser that has never seen the tour + const ctx = await browser.newContext() + await ctx.addInitScript(() => { + try { + localStorage.setItem('loop-studio:storage-mode', 'personal') + } catch { + /* opaque origin */ + } + }) + const page = await ctx.newPage() + await freshGoto(page, '/' + hash) + await expect(prompt(page)).toBeVisible() + await page.waitForTimeout(2500) // the card is offered once the app settles; it must not settle yet + await expect(page.locator('.tour-card')).toHaveCount(0) + await typeAndOpen(page, PASSWORD) + await expect(prompt(page)).toHaveCount(0) + await expect(page.locator('.tour-card')).toBeVisible() + await ctx.close() + }) +}) + +test.describe('with the storage gate (issue #297)', () => { + test.use({ storageMode: 'gate' }) + + test('the gate names the waiting link and keeps its fragment; a temporary session opens it and stores no document', async ({ page, browser }) => { + const { hash } = await linkFromAnotherProfile(browser) + await page.goto('about:blank') + await page.goto('/' + hash) + const gate = page.locator('.gate') + await expect(gate).toBeVisible() + await expect(page.locator('[data-gate-note="share-link"]')).toBeVisible() + expect(await hashOf(page)).toBe(hash) // read and tidied by the app, after the gate + await expect(prompt(page)).toHaveCount(0) + + await gate.locator('[data-gate-choice="temporary"]').locator('button').click() + await expect(prompt(page)).toBeVisible() + expect(await hashOf(page)).toBe('') + await expect(promptPart(page, 'storage-note')).toHaveText('In this temporary session the opened diagram is not kept in this browser.') + await typeAndOpen(page, PASSWORD) + await expect(prompt(page)).toHaveCount(0) + await page.waitForFunction(() => Boolean((window as any).__loop)) + expect(await labelsOf(page)).toEqual(['α ⚙', 'β 보물']) + await page.evaluate(() => (window as unknown as Bridge).__loop.autosave.flush()) + const keys = await page.evaluate(() => Object.keys(localStorage)) + expect(keys).not.toContain(WORK_KEY) + expect(keys.filter((k) => k.startsWith('loop-studio:graph'))).toEqual([]) + }) +}) diff --git a/e2e/whats-new.spec.ts b/e2e/whats-new.spec.ts index 0fa7a373..703b3e6e 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.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.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/src/model/shareProtected.ts b/src/model/shareProtected.ts index ebd92bd9..d9b1ab37 100644 --- a/src/model/shareProtected.ts +++ b/src/model/shareProtected.ts @@ -154,7 +154,7 @@ export async function sealShareText(text: string, password: string): Promise<{ p } /** - * The structure check that runs BEFORE any password is asked for (SS P5): the + * The structure check that runs BEFORE any password is asked for (SS P6): the * length bounds and the strict base64url alphabet. Returns the decoded bytes - * the in-memory copy the caller keeps once the fragment is gone. Throws * `ProtectedShareError('structure')` and nothing else. From 2a09f212e4b6d198890cf4bb2aab7c42b15a3756 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:02:29 +0900 Subject: [PATCH 6/8] fix(share): wording, the pt-PT bound, and a composition guard a test can hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #300. Four corrections from the review of the first full run, which passed at the previous commit and is kept as a diagnostic. The recovery sentence. "There is no server and no reset" could be read as a statement about the whole service. The note now says `Loop Studio cannot recover or reset the password.`, in all eighteen languages. The release note. "A wrong password and a damaged link give the same message" claimed too much: a link whose structure is broken is not asked a password for and gets a different notice. The line now says `A wrong password and encrypted link data that was altered after creation give the same message.`, in all eighteen languages. The pt-PT guard. The bound on how much of the catalog may differ from pt-BR is a quarter again, for everything outside this issue's password strings. Those strings are held separately: `senha` joins the banned Brazilian forms, which pins `palavra-passe` in both directions (none left in pt-PT, every pt-BR string that has it changed), and the 28 of the 33 keys that differ are listed exactly. The composition guard. The inline check read the key event's own `isComposing`, which no test could set. `useCompositionGuard` tracks `compositionstart` and `compositionend` itself, and still honours `isComposing` and key code 229. Two tests dispatch those two events by hand around a real Enter key press, in the creating dialog and in the opening prompt: the Enter inside does not submit, the one after does. With the tracked state removed from the guard both tests fail. They pin the guard; they are not a test of a real input method. Guards that follow the two strings: `изменённые` replaces `повреждённая` in the Russian list of spellings with the letter yo, and the recovery sentence joins the Thai and Vietnamese lists of strings that name Loop Studio. --- docs/specs/SEMANTICS-P.md | 8 +-- e2e/share-protected.spec.ts | 36 ++++++++++++++ src/components/ProtectedLinkDialog.tsx | 13 ++--- src/components/toolbar/ShareCreateDialog.tsx | 18 ++++--- src/components/useCompositionGuard.ts | 34 +++++++++++++ src/i18n/locales/ar/ui.ts | 4 +- src/i18n/locales/de/ui.ts | 4 +- src/i18n/locales/en/ui.ts | 4 +- src/i18n/locales/es-419/ui.ts | 4 +- src/i18n/locales/es-ES/ui.ts | 4 +- src/i18n/locales/fr/ui.ts | 4 +- src/i18n/locales/it/ui.ts | 4 +- src/i18n/locales/ja/ui.ts | 4 +- src/i18n/locales/ko/ui.ts | 4 +- src/i18n/locales/nl/ui.ts | 4 +- src/i18n/locales/pt-BR/ui.ts | 4 +- src/i18n/locales/pt-PT/ui.ts | 4 +- src/i18n/locales/ru/ui.ts | 4 +- src/i18n/locales/th/ui.ts | 4 +- src/i18n/locales/tr/ui.ts | 4 +- src/i18n/locales/vi/ui.ts | 4 +- src/i18n/locales/zh-Hans/ui.ts | 4 +- src/i18n/locales/zh-Hant/ui.ts | 4 +- src/i18n/ptPtCopy.test.ts | 51 ++++++++++++++++++-- src/i18n/ruCopy.test.ts | 5 +- src/i18n/thCopy.test.ts | 3 +- src/i18n/viCopy.test.ts | 3 +- 27 files changed, 182 insertions(+), 61 deletions(-) create mode 100644 src/components/useCompositionGuard.ts diff --git a/docs/specs/SEMANTICS-P.md b/docs/specs/SEMANTICS-P.md index 97e3811c..ec685a41 100644 --- a/docs/specs/SEMANTICS-P.md +++ b/docs/specs/SEMANTICS-P.md @@ -123,8 +123,8 @@ already open document does not open it, as with a plain link. - The plain link is the default and behaves as `loop-share/1` says. Protection is an explicit choice in the same dialog. - The dialog tells the sender to send the password some other way than the link, - that a lost password cannot be recovered, and that the link is only as strong - as its password. + that Loop Studio cannot recover or reset the password, and that the link is + only as strong as its password. - The outbound cap applies to the protected payload exactly as to a plain one: `SHARE_MAX_BYTES` (8 KiB) characters after `#p1=`. Over the cap is a hard reject shown inside the dialog, never a truncation. A document can therefore @@ -163,7 +163,9 @@ issue #297's. One derivation runs at a time. Cancel leaves the current document and any run in progress untouched; a derivation still running when Cancel is pressed is ignored -when it finishes. A press on the backdrop does not dismiss the prompt. +when it finishes. A press on the backdrop does not dismiss the prompt. An Enter +that only commits an input-method composition does not submit, here or in the +creating dialog. After a successful open the rules of `loop-share/1` apply: the replace confirmation unless the session is the untouched sample, one load, no auto-run. diff --git a/e2e/share-protected.spec.ts b/e2e/share-protected.spec.ts index 62e9c9c1..bb7c1223 100644 --- a/e2e/share-protected.spec.ts +++ b/e2e/share-protected.spec.ts @@ -181,6 +181,23 @@ test.describe('creating a link', () => { await expect(field(page, 'problem')).toHaveCount(0) }) + // synthetic composition events around a real Enter, as in the opening test below: + // this pins the guard, it is not a test of a real input method + test('an Enter inside a composition does not submit the create form; after the composition ends it does', async ({ page }) => { + await shareBtn(page).click() + await field(page, 'option').check() + await field(page, 'password').fill(PASSWORD) + await field(page, 'confirm').fill(PASSWORD + '!') // a submit would say so at once + for (const name of ['password', 'confirm']) { + await field(page, name).dispatchEvent('compositionstart') + await field(page, name).press('Enter') + await expect(field(page, 'problem')).toHaveCount(0) + await field(page, name).dispatchEvent('compositionend') + } + await field(page, 'confirm').press('Enter') + await expect(field(page, 'problem')).toHaveText('The two passwords are not the same.') + }) + test('a protected link: `#p1=` on the public base, the LINK copied, the address bar untouched, the password nowhere', async ({ page }) => { const before = await page.evaluate(() => location.href) const url = await createProtected(page) @@ -388,6 +405,25 @@ test.describe('opening a link', () => { expect(await page.evaluate(() => (window as any).__derives)).toBe(1) }) + // NOT a test of any real input method. `compositionstart` and `compositionend` + // are dispatched by hand around a real Enter key press, which is enough to pin + // the guard: without it the first Enter submits the wrong password, and a + // second key derivation is counted. + test('an Enter inside a composition does not submit; after the composition ends it does (synthetic composition events)', async ({ page }) => { + await countDerivations(page) + await freshGoto(page, '/' + hash) + const input = promptPart(page, 'password') + await input.fill('not the password at all') + await input.dispatchEvent('compositionstart') + await input.press('Enter') + await input.dispatchEvent('compositionend') + await input.fill(PASSWORD) + await input.press('Enter') + await expect(prompt(page)).toHaveCount(0) + expect(await labelsOf(page)).toEqual(['α ⚙', 'β 보물']) + expect(await page.evaluate(() => (window as any).__derives)).toBe(1) + }) + test('a link sealed by an independent implementation opens; sealed content that is not a diagram is refused without a retry', async ({ page }) => { await freshGoto(page, '/#p1=' + nodeSeal(json, PASSWORD)) await typeAndOpen(page, PASSWORD) diff --git a/src/components/ProtectedLinkDialog.tsx b/src/components/ProtectedLinkDialog.tsx index ff1416bf..d9895e70 100644 --- a/src/components/ProtectedLinkDialog.tsx +++ b/src/components/ProtectedLinkDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useRef, useState } from 'react' -import type { KeyboardEvent, SyntheticEvent } from 'react' +import type { SyntheticEvent } from 'react' import { useT, type MessageKey } from '../i18n' import { PROTECTED_PASSWORD_MAX } from '../model/shareProtected' import { @@ -11,6 +11,7 @@ import { } from '../store/protectedLink' import { selectTemporary, useSessionStore } from '../store/sessionStore' import { DialogScrim } from './DialogScrim' +import { useCompositionGuard } from './useCompositionGuard' import { useDialogFocus } from './useDialogFocus' // Issue #300 — what a person sees when a protected share link is opened @@ -50,6 +51,8 @@ function Prompt({ busy, failures }: { busy: boolean; failures: number }) { const errorId = useId() const [shown, setShown] = useState(false) const [empty, setEmpty] = useState(true) + // an Enter that only commits an input-method composition must not submit + const guard = useCompositionGuard() useDialogFocus(true, ref, cancelProtectedOpen) // a wrong answer: the field keeps what was typed, selected, ready to be @@ -67,10 +70,6 @@ function Prompt({ busy, failures }: { busy: boolean; failures: number }) { if (password === '') return void submitProtectedPassword(password) // one at a time: the flow ignores a second call } - // Enter that only commits an IME composition must not submit the form - const guardComposition = (e: KeyboardEvent) => { - if (e.key === 'Enter' && (e.nativeEvent.isComposing || e.keyCode === 229)) e.preventDefault() - } return ( @@ -108,7 +107,9 @@ function Prompt({ busy, failures }: { busy: boolean; failures: number }) { aria-invalid={failures > 0 ? true : undefined} aria-describedby={failures > 0 ? errorId : undefined} data-protected-open="password" - onKeyDown={guardComposition} + onCompositionStart={guard.onCompositionStart} + onCompositionEnd={guard.onCompositionEnd} + onKeyDown={guard.onKeyDown} onInput={(e) => setEmpty(e.currentTarget.value === '')} /> diff --git a/src/components/toolbar/ShareCreateDialog.tsx b/src/components/toolbar/ShareCreateDialog.tsx index 916c772d..c7ea4d62 100644 --- a/src/components/toolbar/ShareCreateDialog.tsx +++ b/src/components/toolbar/ShareCreateDialog.tsx @@ -1,9 +1,10 @@ import { useEffect, useId, useRef, useState } from 'react' -import type { KeyboardEvent, SyntheticEvent } from 'react' +import type { SyntheticEvent } from 'react' import { useT, type MessageKey } from '../../i18n' import { passwordRule, protectedShareAvailable } from '../../model/shareProtected' import { shareKb, type ProtectedShareLinkResult } from '../../ui/shareAction' import { DialogScrim } from '../DialogScrim' +import { useCompositionGuard } from '../useCompositionGuard' import { useDialogFocus } from '../useDialogFocus' import { shareDisclosureBody } from './shareDisclosure' @@ -72,6 +73,9 @@ function ShareCreateBody({ temporary, onCancel, onCreatePlain, onCreateProtected // one creation at a time; a ref, because two clicks in one tick both see the // same render's `busy` (see `useShareSurface`) const busyRef = useRef(false) + // an Enter that only commits an input-method composition must not submit + const guardPassword = useCompositionGuard() + const guardConfirm = useCompositionGuard() const available = protectedShareAvailable() useDialogFocus(true, ref, () => { @@ -119,10 +123,6 @@ function ShareCreateBody({ temporary, onCancel, onCreatePlain, onCreateProtected } } - // Enter that only commits an IME composition must not submit the form - const guardComposition = (e: KeyboardEvent) => { - if (e.key === 'Enter' && (e.nativeEvent.isComposing || e.keyCode === 229)) e.preventDefault() - } const problemText = problem == null @@ -183,7 +183,9 @@ function ShareCreateBody({ temporary, onCancel, onCreatePlain, onCreateProtected aria-describedby={problem ? `${ruleId} ${problemId}` : ruleId} aria-invalid={problem?.kind === 'short' || problem?.kind === 'long' ? true : undefined} data-share-protect="password" - onKeyDown={guardComposition} + onCompositionStart={guardPassword.onCompositionStart} + onCompositionEnd={guardPassword.onCompositionEnd} + onKeyDown={guardPassword.onKeyDown} onInput={() => setProblem(null)} /> @@ -202,7 +204,9 @@ function ShareCreateBody({ temporary, onCancel, onCreatePlain, onCreateProtected aria-invalid={problem?.kind === 'mismatch' ? true : undefined} aria-describedby={problem?.kind === 'mismatch' ? problemId : undefined} data-share-protect="confirm" - onKeyDown={guardComposition} + onCompositionStart={guardConfirm.onCompositionStart} + onCompositionEnd={guardConfirm.onCompositionEnd} + onKeyDown={guardConfirm.onKeyDown} onInput={() => setProblem(null)} /> diff --git a/src/components/useCompositionGuard.ts b/src/components/useCompositionGuard.ts new file mode 100644 index 00000000..df0341dd --- /dev/null +++ b/src/components/useCompositionGuard.ts @@ -0,0 +1,34 @@ +import { useRef } from 'react' +import type { KeyboardEvent } from 'react' + +/** + * Issue #300 — an Enter that only commits an input-method composition (Hangul, + * kana, pinyin) must not submit the form the field is in. A password field + * shows this as soon as it is revealed, because a text field takes the input + * method that a password field does not. + * + * Three signals, any of which means "this Enter belongs to the input method": + * - a composition this field has seen start and not yet end + * (`compositionstart` / `compositionend`, tracked here); + * - `isComposing` on the key event; + * - key code 229: Safari ends the composition BEFORE the keydown that + * committed it, so by then the first two already say "not composing". + * + * Spread the returned handlers on the . The form's own `onSubmit` needs + * no change: an Enter that is prevented here never reaches implicit submission. + */ +export function useCompositionGuard() { + const composing = useRef(false) + return { + onCompositionStart: () => { + composing.current = true + }, + onCompositionEnd: () => { + composing.current = false + }, + onKeyDown: (e: KeyboardEvent) => { + if (e.key !== 'Enter') return + if (composing.current || e.nativeEvent.isComposing || e.keyCode === 229) e.preventDefault() + }, + } +} diff --git a/src/i18n/locales/ar/ui.ts b/src/i18n/locales/ar/ui.ts index 96d30dc3..7ca91ec2 100644 --- a/src/i18n/locales/ar/ui.ts +++ b/src/i18n/locales/ar/ui.ts @@ -531,7 +531,7 @@ const ui = { 'share.protect.error.long': 'يجب ألا تزيد كلمة المرور على 128 حرفًا.', 'share.protect.error.mismatch': 'كلمتا المرور غير متطابقتين.', 'share.protect.note.channel': 'أرسل كلمة المرور بطريقة أخرى غير طريقة الرابط، وليس في الرسالة نفسها.', - 'share.protect.note.lost': 'لا يمكن استرجاع كلمة مرور مفقودة. لا يوجد خادم ولا إعادة تعيين.', + 'share.protect.note.lost': 'لا يستطيع Loop Studio استرجاع كلمة المرور ولا إعادة تعيينها.', 'share.protect.note.strength': 'قوة الرابط من قوة كلمة المرور: من يملك الرابط يستطيع مواصلة التخمين بلا حد.', 'share.protect.note.contract': 'لا يخزّن Loop Studio كلمة المرور ولا المفتاح ولا يرسلهما. قد يعرض متصفحك أو مدير كلمات المرور حفظها.', 'share.protect.busy': 'جارٍ التشفير…', @@ -550,7 +550,7 @@ const ui = { 'share.open.notice.content': 'كلمة المرور صحيحة، لكن الرابط لا يحتوي على مخطط يستطيع Loop Studio فتحه. اطلب رابطًا جديدًا من المرسل.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'يمكن حماية رابط المشاركة بكلمة مرور: يُشفَّر المخطط داخل الرابط، ولا يستطيع فتحه إلا من يملك كلمة المرور.', - 'whatsNew.v0170.open': 'يطلب الرابط المحمي كلمة المرور قبل عرض أي شيء من المخطط المشترك. كلمة المرور الخاطئة والرابط التالف يعطيان الرسالة نفسها.', + 'whatsNew.v0170.open': 'يطلب الرابط المحمي كلمة المرور قبل عرض أي شيء من المخطط المشترك. كلمة المرور الخاطئة وبيانات الرابط المشفّرة التي غُيّرت بعد إنشائه تعطيان الرسالة نفسها.', 'whatsNew.v0170.limits': 'لا يمكن استرجاع كلمة مرور مفقودة، وقوة الرابط من قوة كلمة المرور. لا يخزّن Loop Studio كلمة المرور ولا المفتاح ولا يرسلهما.', 'whatsNew.v0170.plain': 'الرابط العادي ما زال الخيار الافتراضي ويعمل كما كان.', } as const diff --git a/src/i18n/locales/de/ui.ts b/src/i18n/locales/de/ui.ts index fd2ce9ab..d8812bcd 100644 --- a/src/i18n/locales/de/ui.ts +++ b/src/i18n/locales/de/ui.ts @@ -566,7 +566,7 @@ const ui = { 'share.protect.error.long': 'Das Passwort darf höchstens 128 Zeichen haben.', 'share.protect.error.mismatch': 'Die beiden Passwörter stimmen nicht überein.', 'share.protect.note.channel': 'Schicke das Passwort auf einem anderen Weg als den Link, nicht in derselben Nachricht.', - 'share.protect.note.lost': 'Ein verlorenes Passwort lässt sich nicht wiederherstellen. Es gibt keinen Server und kein Zurücksetzen.', + 'share.protect.note.lost': 'Loop Studio kann das Passwort weder wiederherstellen noch zurücksetzen.', 'share.protect.note.strength': 'Der Link ist nur so stark wie sein Passwort: Wer den Link hat, kann unbegrenzt weiterraten.', 'share.protect.note.contract': 'Loop Studio speichert und überträgt weder das Passwort noch den Schlüssel. Dein Browser oder ein Passwortmanager kann anbieten, es zu speichern.', 'share.protect.busy': 'Wird verschlüsselt …', @@ -585,7 +585,7 @@ const ui = { 'share.open.notice.content': 'Das Passwort stimmt, aber der Link enthält kein Diagramm, das Loop Studio öffnen kann. Bitte den Absender um einen neuen Link.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'Ein Link zum Teilen lässt sich mit einem Passwort schützen: Das Diagramm wird im Link verschlüsselt, und nur wer das Passwort hat, kann es öffnen.', - 'whatsNew.v0170.open': 'Ein geschützter Link fragt nach seinem Passwort, bevor irgendetwas vom geteilten Diagramm gezeigt wird. Ein falsches Passwort und ein beschädigter Link ergeben dieselbe Meldung.', + '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.', } satisfies Record diff --git a/src/i18n/locales/en/ui.ts b/src/i18n/locales/en/ui.ts index a3c0f448..e733163f 100644 --- a/src/i18n/locales/en/ui.ts +++ b/src/i18n/locales/en/ui.ts @@ -475,7 +475,7 @@ const ui = { 'share.protect.error.long': 'The password can have at most 128 characters.', 'share.protect.error.mismatch': 'The two passwords are not the same.', 'share.protect.note.channel': 'Send the password some other way than the link, not in the same message.', - 'share.protect.note.lost': 'A lost password cannot be recovered. There is no server and no reset.', + 'share.protect.note.lost': 'Loop Studio cannot recover or reset the password.', 'share.protect.note.strength': 'The link is only as strong as its password: anyone who has the link can keep guessing, with no limit.', 'share.protect.note.contract': 'Loop Studio does not store or transmit the password or the key. Your browser or a password manager may offer to save it.', 'share.protect.busy': 'Encrypting…', @@ -494,7 +494,7 @@ const ui = { 'share.open.notice.content': 'The password is correct, but the link does not contain a diagram that Loop Studio can open. Ask its sender for a new link.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'A share link can be protected with a password: the diagram is encrypted inside the link, and only someone who has the password can open it.', - 'whatsNew.v0170.open': 'A protected link asks for its password before anything from the shared diagram is shown. A wrong password and a damaged link give the same message.', + '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.', } as const diff --git a/src/i18n/locales/es-419/ui.ts b/src/i18n/locales/es-419/ui.ts index f49e481f..cca571c3 100644 --- a/src/i18n/locales/es-419/ui.ts +++ b/src/i18n/locales/es-419/ui.ts @@ -489,7 +489,7 @@ const ui = { 'share.protect.error.long': 'La contraseña puede tener como máximo 128 caracteres.', 'share.protect.error.mismatch': 'Las dos contraseñas no son iguales.', 'share.protect.note.channel': 'Envía la contraseña por otra vía distinta del enlace, no en el mismo mensaje.', - 'share.protect.note.lost': 'Una contraseña perdida no se puede recuperar. No hay servidor ni restablecimiento.', + 'share.protect.note.lost': 'Loop Studio no puede recuperar ni restablecer la contraseña.', 'share.protect.note.strength': 'El enlace es tan fuerte como su contraseña: quien tenga el enlace puede seguir probando, sin límite.', 'share.protect.note.contract': 'Loop Studio no almacena ni transmite la contraseña ni la clave. El navegador o un administrador de contraseñas puede ofrecer guardarla.', 'share.protect.busy': 'Cifrando…', @@ -508,7 +508,7 @@ const ui = { 'share.open.notice.content': 'La contraseña es correcta, pero el enlace no contiene un diagrama que Loop Studio pueda abrir. Pide un enlace nuevo a quien lo envió.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'Un enlace para compartir puede protegerse con una contraseña: el diagrama se cifra dentro del enlace, y solo quien tenga la contraseña puede abrirlo.', - 'whatsNew.v0170.open': 'Un enlace protegido pide su contraseña antes de mostrar nada del diagrama compartido. Una contraseña incorrecta y un enlace dañado dan el mismo mensaje.', + '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.', } as const diff --git a/src/i18n/locales/es-ES/ui.ts b/src/i18n/locales/es-ES/ui.ts index e6a4b750..b5ae7a7a 100644 --- a/src/i18n/locales/es-ES/ui.ts +++ b/src/i18n/locales/es-ES/ui.ts @@ -503,7 +503,7 @@ const ui = { 'share.protect.error.long': 'La contraseña puede tener como máximo 128 caracteres.', 'share.protect.error.mismatch': 'Las dos contraseñas no son iguales.', 'share.protect.note.channel': 'Envíe la contraseña por otra vía distinta del enlace, no en el mismo mensaje.', - 'share.protect.note.lost': 'Una contraseña perdida no se puede recuperar. No hay servidor ni restablecimiento.', + 'share.protect.note.lost': 'Loop Studio no puede recuperar ni restablecer la contraseña.', 'share.protect.note.strength': 'El enlace es tan fuerte como su contraseña: quien tenga el enlace puede seguir probando, sin límite.', 'share.protect.note.contract': 'Loop Studio no almacena ni transmite la contraseña ni la clave. El navegador o un gestor de contraseñas puede ofrecerse a guardarla.', 'share.protect.busy': 'Cifrando…', @@ -522,7 +522,7 @@ const ui = { 'share.open.notice.content': 'La contraseña es correcta, pero el enlace no contiene un diagrama que Loop Studio pueda abrir. Pida un enlace nuevo a quien lo envió.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'Un enlace para compartir puede protegerse con una contraseña: el diagrama se cifra dentro del enlace, y solo quien tenga la contraseña puede abrirlo.', - 'whatsNew.v0170.open': 'Un enlace protegido pide su contraseña antes de mostrar nada del diagrama compartido. Una contraseña incorrecta y un enlace dañado dan el mismo mensaje.', + '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.', } as const diff --git a/src/i18n/locales/fr/ui.ts b/src/i18n/locales/fr/ui.ts index 6f428934..630ed4d5 100644 --- a/src/i18n/locales/fr/ui.ts +++ b/src/i18n/locales/fr/ui.ts @@ -560,7 +560,7 @@ const ui = { 'share.protect.error.long': 'Le mot de passe peut compter au plus 128 caractères.', 'share.protect.error.mismatch': 'Les deux mots de passe ne sont pas identiques.', 'share.protect.note.channel': 'Envoyez le mot de passe par un autre moyen que le lien, pas dans le même message.', - 'share.protect.note.lost': 'Un mot de passe perdu ne peut pas être récupéré. Il n’y a ni serveur ni réinitialisation.', + 'share.protect.note.lost': 'Loop Studio ne peut ni récupérer ni réinitialiser le mot de passe.', 'share.protect.note.strength': 'Le lien n’est pas plus solide que son mot de passe : quiconque a le lien peut continuer à deviner, sans limite.', 'share.protect.note.contract': 'Loop Studio ne conserve ni ne transmet le mot de passe ou la clé. Votre navigateur ou un gestionnaire de mots de passe peut proposer de l’enregistrer.', 'share.protect.busy': 'Chiffrement…', @@ -579,7 +579,7 @@ const ui = { 'share.open.notice.content': 'Le mot de passe est correct, mais le lien ne contient pas de diagramme que Loop Studio puisse ouvrir. Demandez un nouveau lien à son expéditeur.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'Un lien de partage peut être protégé par un mot de passe : le diagramme est chiffré dans le lien, et seule une personne qui a le mot de passe peut l’ouvrir.', - '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 un lien endommagé donnent le même message.', + '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.', } satisfies Record diff --git a/src/i18n/locales/it/ui.ts b/src/i18n/locales/it/ui.ts index 624de4a5..d44dc6bc 100644 --- a/src/i18n/locales/it/ui.ts +++ b/src/i18n/locales/it/ui.ts @@ -505,7 +505,7 @@ const ui = { 'share.protect.error.long': 'La password può avere al massimo 128 caratteri.', 'share.protect.error.mismatch': 'Le due password non coincidono.', 'share.protect.note.channel': 'Invia la password per un’altra via rispetto al link, non nello stesso messaggio.', - 'share.protect.note.lost': 'Una password persa non si può recuperare. Non c’è un server e non c’è un ripristino.', + 'share.protect.note.lost': 'Loop Studio non può recuperare né reimpostare la password.', 'share.protect.note.strength': 'Il link è forte solo quanto la sua password: chi ha il link può continuare a tentare, senza limiti.', 'share.protect.note.contract': 'Loop Studio non conserva né trasmette la password o la chiave. Il browser o un gestore di password può proporre di salvarla.', 'share.protect.busy': 'Cifratura in corso…', @@ -524,7 +524,7 @@ const ui = { 'share.open.notice.content': 'La password è corretta, ma il link non contiene un diagramma che Loop Studio possa aprire. Chiedi un nuovo link a chi lo ha inviato.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'Un link di condivisione può essere protetto da una password: il diagramma viene cifrato dentro il link, e solo chi ha la password può aprirlo.', - 'whatsNew.v0170.open': 'Un link protetto chiede la password prima che venga mostrato qualcosa del diagramma condiviso. Una password sbagliata e un link danneggiato danno lo stesso messaggio.', + '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.', } satisfies Record diff --git a/src/i18n/locales/ja/ui.ts b/src/i18n/locales/ja/ui.ts index 0907d4b5..d73b7406 100644 --- a/src/i18n/locales/ja/ui.ts +++ b/src/i18n/locales/ja/ui.ts @@ -510,7 +510,7 @@ const ui = { 'share.protect.error.long': 'パスワードは128文字以下にしてください。', 'share.protect.error.mismatch': '2つのパスワードが一致しません。', 'share.protect.note.channel': 'パスワードはリンクとは別の方法で送ってください。同じメッセージには入れないでください。', - 'share.protect.note.lost': '忘れたパスワードは復元できません。サーバーもリセットもありません。', + 'share.protect.note.lost': 'Loop Studio ではパスワードを復元することも、再設定することもできません。', 'share.protect.note.strength': 'リンクの強さはパスワードの強さで決まります。リンクを持つ人は、回数の制限なく推測を続けられます。', 'share.protect.note.contract': 'Loop Studio はパスワードも鍵も保存せず、送信もしません。ブラウザーやパスワードマネージャーが保存を提案することはあります。', 'share.protect.busy': '暗号化しています…', @@ -529,7 +529,7 @@ const ui = { 'share.open.notice.content': 'パスワードは正しいのですが、リンクには Loop Studio で開けるダイアグラムが含まれていません。送信者に新しいリンクを依頼してください。', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': '共有リンクをパスワードで保護できます。ダイアグラムはリンクの中で暗号化され、パスワードを持つ人だけが開けます。', - 'whatsNew.v0170.open': '保護されたリンクは、共有されたダイアグラムの内容を何も表示する前にパスワードを尋ねます。パスワードの誤りとリンクの破損では、同じメッセージが表示されます。', + 'whatsNew.v0170.open': '保護されたリンクは、共有されたダイアグラムの内容を何も表示する前にパスワードを尋ねます。パスワードの誤りと、作成後に変更された暗号化リンクデータでは、同じメッセージが表示されます。', 'whatsNew.v0170.limits': '忘れたパスワードは復元できず、リンクの強さはパスワードの強さで決まります。Loop Studio はパスワードも鍵も保存せず、送信もしません。', 'whatsNew.v0170.plain': '通常のリンクが引き続き既定で、これまでどおり動作します。', } satisfies Record diff --git a/src/i18n/locales/ko/ui.ts b/src/i18n/locales/ko/ui.ts index 3ee2b7f5..16ba5090 100644 --- a/src/i18n/locales/ko/ui.ts +++ b/src/i18n/locales/ko/ui.ts @@ -478,7 +478,7 @@ const ui = { 'share.protect.error.long': '비밀번호는 128자 이하여야 합니다.', 'share.protect.error.mismatch': '두 비밀번호가 서로 다릅니다.', 'share.protect.note.channel': '비밀번호는 링크와 다른 경로로 보내세요. 같은 메시지에 함께 넣지 마세요.', - 'share.protect.note.lost': '비밀번호를 잊으면 되찾을 수 없습니다. 서버도, 재설정도 없습니다.', + 'share.protect.note.lost': 'Loop Studio에서는 비밀번호를 복구하거나 재설정할 수 없습니다.', 'share.protect.note.strength': '링크는 비밀번호만큼만 안전합니다. 링크를 가진 사람은 횟수 제한 없이 계속 추측해 볼 수 있습니다.', 'share.protect.note.contract': 'Loop Studio는 비밀번호와 키를 저장하거나 전송하지 않습니다. 브라우저나 비밀번호 관리자가 저장을 제안할 수는 있습니다.', 'share.protect.busy': '암호화하는 중…', @@ -497,7 +497,7 @@ const ui = { 'share.open.notice.content': '비밀번호는 맞지만 링크에 Loop Studio가 열 수 있는 다이어그램이 들어 있지 않습니다. 보낸 사람에게 새 링크를 요청하세요.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': '공유 링크를 비밀번호로 보호할 수 있습니다. 다이어그램이 링크 안에 암호화되어 들어가며, 비밀번호를 가진 사람만 열 수 있습니다.', - 'whatsNew.v0170.open': '보호된 링크는 공유된 다이어그램의 어떤 것도 보이기 전에 비밀번호를 묻습니다. 틀린 비밀번호와 손상된 링크는 같은 안내가 나옵니다.', + 'whatsNew.v0170.open': '보호된 링크는 공유된 다이어그램의 어떤 것도 보이기 전에 비밀번호를 묻습니다. 틀린 비밀번호와 생성된 뒤 변경된 암호화 링크 데이터에는 같은 안내가 나옵니다.', 'whatsNew.v0170.limits': '잊은 비밀번호는 되찾을 수 없고, 링크는 비밀번호만큼만 안전합니다. Loop Studio는 비밀번호와 키를 저장하거나 전송하지 않습니다.', 'whatsNew.v0170.plain': '일반 링크가 여전히 기본이며 이전과 같이 동작합니다.', } satisfies Record diff --git a/src/i18n/locales/nl/ui.ts b/src/i18n/locales/nl/ui.ts index 6bfe4647..ab2e59ff 100644 --- a/src/i18n/locales/nl/ui.ts +++ b/src/i18n/locales/nl/ui.ts @@ -486,7 +486,7 @@ const ui = { 'share.protect.error.long': 'Het wachtwoord mag hoogstens 128 tekens hebben.', 'share.protect.error.mismatch': 'De twee wachtwoorden zijn niet gelijk.', 'share.protect.note.channel': 'Stuur het wachtwoord langs een andere weg dan de link, niet in hetzelfde bericht.', - 'share.protect.note.lost': 'Een verloren wachtwoord kan niet worden teruggehaald. Er is geen server en geen herstelmogelijkheid.', + 'share.protect.note.lost': 'Loop Studio kan het wachtwoord niet terughalen of opnieuw instellen.', 'share.protect.note.strength': 'De link is maar zo sterk als het wachtwoord: wie de link heeft kan onbeperkt blijven raden.', 'share.protect.note.contract': 'Loop Studio bewaart en verstuurt het wachtwoord en de sleutel niet. Je browser of een wachtwoordbeheerder kan aanbieden het op te slaan.', 'share.protect.busy': 'Bezig met versleutelen…', @@ -505,7 +505,7 @@ const ui = { 'share.open.notice.content': 'Het wachtwoord klopt, maar de link bevat geen diagram dat Loop Studio kan openen. Vraag de afzender om een nieuwe link.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'Een deellink kan met een wachtwoord worden beveiligd: het diagram wordt in de link versleuteld, en alleen wie het wachtwoord heeft kan het openen.', - 'whatsNew.v0170.open': 'Een beveiligde link vraagt om het wachtwoord voordat er iets van het gedeelde diagram wordt getoond. Een verkeerd wachtwoord en een beschadigde link geven dezelfde melding.', + '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.', } as const diff --git a/src/i18n/locales/pt-BR/ui.ts b/src/i18n/locales/pt-BR/ui.ts index 4b149399..3ab79be7 100644 --- a/src/i18n/locales/pt-BR/ui.ts +++ b/src/i18n/locales/pt-BR/ui.ts @@ -491,7 +491,7 @@ const ui = { 'share.protect.error.long': 'A senha pode ter no máximo 128 caracteres.', 'share.protect.error.mismatch': 'As duas senhas não são iguais.', 'share.protect.note.channel': 'Envie a senha por outro caminho que não o do link, não na mesma mensagem.', - 'share.protect.note.lost': 'Uma senha perdida não pode ser recuperada. Não há servidor nem redefinição.', + 'share.protect.note.lost': 'O Loop Studio não pode recuperar nem redefinir a senha.', 'share.protect.note.strength': 'O link é tão forte quanto a senha: quem tem o link pode continuar tentando, sem limite.', 'share.protect.note.contract': 'O Loop Studio não armazena nem transmite a senha nem a chave. O navegador ou um gerenciador de senhas pode oferecer para salvá-la.', 'share.protect.busy': 'Criptografando…', @@ -510,7 +510,7 @@ const ui = { 'share.open.notice.content': 'A senha está certa, mas o link não contém um diagrama que o Loop Studio consiga abrir. Peça um novo link a quem o enviou.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'Um link de compartilhamento pode ser protegido com uma senha: o diagrama é criptografado dentro do link, e só quem tem a senha pode abri-lo.', - 'whatsNew.v0170.open': 'Um link protegido pede a senha antes de mostrar qualquer coisa do diagrama compartilhado. Uma senha errada e um link danificado dão a mesma mensagem.', + '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.', } as const diff --git a/src/i18n/locales/pt-PT/ui.ts b/src/i18n/locales/pt-PT/ui.ts index 546a8a95..6ffb8d15 100644 --- a/src/i18n/locales/pt-PT/ui.ts +++ b/src/i18n/locales/pt-PT/ui.ts @@ -505,7 +505,7 @@ const ui = { 'share.protect.error.long': 'A palavra-passe pode ter no máximo 128 caracteres.', 'share.protect.error.mismatch': 'As duas palavras-passe não são iguais.', 'share.protect.note.channel': 'Envie a palavra-passe por outra via que não a da ligação, não na mesma mensagem.', - 'share.protect.note.lost': 'Uma palavra-passe perdida não pode ser recuperada. Não há servidor nem reposição.', + 'share.protect.note.lost': 'O Loop Studio não pode recuperar nem repor a palavra-passe.', 'share.protect.note.strength': 'A ligação é tão forte quanto a palavra-passe: quem tem a ligação pode continuar a tentar, sem limite.', 'share.protect.note.contract': 'O Loop Studio não armazena nem transmite a palavra-passe nem a chave. O navegador ou um gestor de palavras-passe pode propor guardá-la.', 'share.protect.busy': 'A cifrar…', @@ -524,7 +524,7 @@ const ui = { 'share.open.notice.content': 'A palavra-passe está certa, mas a ligação não contém um diagrama que o Loop Studio consiga abrir. Peça uma nova ligação a quem a enviou.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'Um link de partilha pode ser protegido com uma palavra-passe: o diagrama é cifrado dentro da ligação, e só quem tem a palavra-passe o pode abrir.', - 'whatsNew.v0170.open': 'Uma ligação protegida pede a palavra-passe antes de mostrar qualquer coisa do diagrama partilhado. Uma palavra-passe errada e uma ligação danificada dão a mesma mensagem.', + '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.', } as const diff --git a/src/i18n/locales/ru/ui.ts b/src/i18n/locales/ru/ui.ts index c7ff4f4f..b3d46407 100644 --- a/src/i18n/locales/ru/ui.ts +++ b/src/i18n/locales/ru/ui.ts @@ -559,7 +559,7 @@ const ui = { 'share.protect.error.long': 'В пароле может быть не больше 128 символов.', 'share.protect.error.mismatch': 'Пароли не совпадают.', 'share.protect.note.channel': 'Отправьте пароль другим способом, не тем же сообщением, что и ссылку.', - 'share.protect.note.lost': 'Потерянный пароль нельзя восстановить. Нет ни сервера, ни сброса.', + 'share.protect.note.lost': 'Loop Studio не может ни восстановить, ни сбросить пароль.', 'share.protect.note.strength': 'Ссылка надёжна ровно настолько, насколько надёжен пароль: тот, у кого есть ссылка, может подбирать его без ограничений.', 'share.protect.note.contract': 'Loop Studio не хранит и не передаёт ни пароль, ни ключ. Браузер или менеджер паролей может предложить сохранить его.', 'share.protect.busy': 'Шифрование…', @@ -578,7 +578,7 @@ const ui = { 'share.open.notice.content': 'Пароль верный, но в ссылке нет диаграммы, которую Loop Studio может открыть. Попросите у отправителя новую ссылку.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'Ссылку для доступа можно защитить паролем: диаграмма шифруется внутри ссылки, и открыть её может только тот, у кого есть пароль.', - 'whatsNew.v0170.open': 'Защищённая ссылка спрашивает пароль до того, как будет показано что-либо из общей диаграммы. Неверный пароль и повреждённая ссылка дают одно и то же сообщение.', + 'whatsNew.v0170.open': 'Защищённая ссылка спрашивает пароль до того, как будет показано что-либо из общей диаграммы. Неверный пароль и зашифрованные данные ссылки, изменённые после создания, дают одно и то же сообщение.', 'whatsNew.v0170.limits': 'Потерянный пароль нельзя восстановить, а ссылка надёжна ровно настолько, насколько надёжен пароль. Loop Studio не хранит и не передаёт ни пароль, ни ключ.', 'whatsNew.v0170.plain': 'Обычная ссылка остаётся вариантом по умолчанию и работает как раньше.', } as const diff --git a/src/i18n/locales/th/ui.ts b/src/i18n/locales/th/ui.ts index e7c12766..86452b33 100644 --- a/src/i18n/locales/th/ui.ts +++ b/src/i18n/locales/th/ui.ts @@ -551,7 +551,7 @@ const ui = { 'share.protect.error.long': 'รหัสผ่านมีได้ไม่เกิน 128 อักขระ', 'share.protect.error.mismatch': 'รหัสผ่านทั้งสองไม่ตรงกัน', 'share.protect.note.channel': 'ส่งรหัสผ่านทางช่องทางอื่นที่ไม่ใช่ช่องทางเดียวกับลิงก์ อย่าส่งในข้อความเดียวกัน', - 'share.protect.note.lost': 'รหัสผ่านที่หายไปกู้คืนไม่ได้ ไม่มีเซิร์ฟเวอร์และไม่มีการรีเซ็ต', + 'share.protect.note.lost': 'Loop Studio ไม่สามารถกู้คืนหรือรีเซ็ตรหัสผ่านได้', 'share.protect.note.strength': 'ลิงก์ปลอดภัยได้เท่ากับรหัสผ่านเท่านั้น ใครก็ตามที่มีลิงก์สามารถเดารหัสผ่านต่อไปได้โดยไม่จำกัด', 'share.protect.note.contract': 'Loop Studio ไม่จัดเก็บและไม่ส่งรหัสผ่านหรือกุญแจ เบราว์เซอร์หรือตัวจัดการรหัสผ่านอาจเสนอให้บันทึกรหัสผ่าน', 'share.protect.busy': 'กำลังเข้ารหัส…', @@ -570,7 +570,7 @@ const ui = { 'share.open.notice.content': 'รหัสผ่านถูกต้อง แต่ลิงก์ไม่มีไดอะแกรมที่ Loop Studio เปิดได้ ขอลิงก์ใหม่จากผู้ส่ง', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'ลิงก์แชร์ป้องกันด้วยรหัสผ่านได้ ไดอะแกรมจะถูกเข้ารหัสไว้ในลิงก์ และมีเพียงผู้ที่มีรหัสผ่านเท่านั้นที่เปิดได้', - 'whatsNew.v0170.open': 'ลิงก์ที่ป้องกันไว้จะถามรหัสผ่านก่อนแสดงสิ่งใดจากไดอะแกรมที่แชร์ รหัสผ่านที่ผิดและลิงก์ที่เสียหายจะได้ข้อความเดียวกัน', + 'whatsNew.v0170.open': 'ลิงก์ที่ป้องกันไว้จะถามรหัสผ่านก่อนแสดงสิ่งใดจากไดอะแกรมที่แชร์ รหัสผ่านที่ผิดและข้อมูลลิงก์ที่เข้ารหัสซึ่งถูกเปลี่ยนแปลงหลังจากสร้างจะได้ข้อความเดียวกัน', 'whatsNew.v0170.limits': 'รหัสผ่านที่หายไปกู้คืนไม่ได้ และลิงก์ปลอดภัยได้เท่ากับรหัสผ่านเท่านั้น Loop Studio ไม่จัดเก็บและไม่ส่งรหัสผ่านหรือกุญแจ', 'whatsNew.v0170.plain': 'ลิงก์ธรรมดายังคงเป็นค่าเริ่มต้นและทำงานเหมือนเดิม', } as const diff --git a/src/i18n/locales/tr/ui.ts b/src/i18n/locales/tr/ui.ts index df5d2d11..8850dbe9 100644 --- a/src/i18n/locales/tr/ui.ts +++ b/src/i18n/locales/tr/ui.ts @@ -545,7 +545,7 @@ const ui = { 'share.protect.error.long': 'Parola en fazla 128 karakter olabilir.', 'share.protect.error.mismatch': 'İki parola aynı değil.', 'share.protect.note.channel': 'Parolayı bağlantıdan farklı bir yolla gönderin; aynı mesajda göndermeyin.', - 'share.protect.note.lost': 'Kaybedilen parola kurtarılamaz. Sunucu da yok, sıfırlama da.', + 'share.protect.note.lost': 'Loop Studio parolayı kurtaramaz ve sıfırlayamaz.', 'share.protect.note.strength': 'Bağlantı ancak parolası kadar güçlüdür: bağlantıya sahip olan biri sınırsız kez tahmin etmeyi sürdürebilir.', 'share.protect.note.contract': 'Loop Studio parolayı da anahtarı da saklamaz ve iletmez. Tarayıcınız veya bir parola yöneticisi onu kaydetmeyi önerebilir.', 'share.protect.busy': 'Şifreleniyor…', @@ -564,7 +564,7 @@ const ui = { 'share.open.notice.content': 'Parola doğru, ancak bağlantıda Loop Studio’nun açabileceği bir diyagram yok. Gönderenden yeni bir bağlantı isteyin.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'Paylaşım bağlantısı parolayla korunabilir: diyagram bağlantının içinde şifrelenir ve onu yalnızca parolayı bilen biri açabilir.', - 'whatsNew.v0170.open': 'Korumalı bağlantı, paylaşılan diyagramdan herhangi bir şey gösterilmeden önce parolasını sorar. Yanlış parola ile hasarlı bağlantı aynı mesajı verir.', + '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.', } as const diff --git a/src/i18n/locales/vi/ui.ts b/src/i18n/locales/vi/ui.ts index 64185210..4029ab55 100644 --- a/src/i18n/locales/vi/ui.ts +++ b/src/i18n/locales/vi/ui.ts @@ -491,7 +491,7 @@ const ui = { 'share.protect.error.long': 'Mật khẩu có tối đa 128 ký tự.', 'share.protect.error.mismatch': 'Hai mật khẩu không giống nhau.', 'share.protect.note.channel': 'Hãy gửi mật khẩu bằng một cách khác với liên kết, không gửi trong cùng một tin nhắn.', - 'share.protect.note.lost': 'Mật khẩu đã mất thì không thể khôi phục. Không có máy chủ và không có cách đặt lại.', + 'share.protect.note.lost': 'Loop Studio không thể khôi phục hay đặt lại mật khẩu.', 'share.protect.note.strength': 'Liên kết chỉ an toàn bằng chính mật khẩu của nó: ai có liên kết đều có thể đoán mãi, không giới hạn.', 'share.protect.note.contract': 'Loop Studio không lưu trữ và không truyền đi mật khẩu hay khóa. Trình duyệt hoặc trình quản lý mật khẩu có thể đề nghị lưu mật khẩu.', 'share.protect.busy': 'Đang mã hóa…', @@ -510,7 +510,7 @@ const ui = { 'share.open.notice.content': 'Mật khẩu đúng, nhưng liên kết không chứa sơ đồ mà Loop Studio mở được. Hãy xin người gửi một liên kết mới.', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': 'Liên kết chia sẻ có thể được bảo vệ bằng mật khẩu: sơ đồ được mã hóa ngay trong liên kết, và chỉ người có mật khẩu mới mở được.', - '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à liên kết hỏng cho cùng một thông báo.', + '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.', } satisfies Record diff --git a/src/i18n/locales/zh-Hans/ui.ts b/src/i18n/locales/zh-Hans/ui.ts index 510e1381..a15eb33f 100644 --- a/src/i18n/locales/zh-Hans/ui.ts +++ b/src/i18n/locales/zh-Hans/ui.ts @@ -483,7 +483,7 @@ const ui = { 'share.protect.error.long': '密码最多 128 个字符。', 'share.protect.error.mismatch': '两次输入的密码不一致。', 'share.protect.note.channel': '请通过与链接不同的途径发送密码,不要放在同一条消息里。', - 'share.protect.note.lost': '密码丢失后无法找回。这里没有服务器,也没有重置。', + 'share.protect.note.lost': 'Loop Studio 无法找回或重置密码。', 'share.protect.note.strength': '链接的安全程度取决于密码:拿到链接的人可以不受限制地一直猜下去。', 'share.protect.note.contract': 'Loop Studio 不存储也不传输密码或密钥。浏览器或密码管理器可能会提议保存它。', 'share.protect.busy': '正在加密…', @@ -502,7 +502,7 @@ const ui = { 'share.open.notice.content': '密码正确,但链接中没有 Loop Studio 能打开的图表。请向发送者索取新链接。', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': '分享链接可以用密码保护:图表会在链接内加密,只有持有密码的人才能打开。', - 'whatsNew.v0170.open': '受保护的链接会先询问密码,之后才显示共享图表的任何内容。密码错误和链接损坏给出的是同一条提示。', + 'whatsNew.v0170.open': '受保护的链接会先询问密码,之后才显示共享图表的任何内容。密码错误和创建后被改动的加密链接数据,给出的是同一条提示。', 'whatsNew.v0170.limits': '密码丢失后无法找回,链接的安全程度取决于密码。Loop Studio 不存储也不传输密码或密钥。', 'whatsNew.v0170.plain': '普通链接仍是默认选项,用法与之前相同。', } satisfies Record diff --git a/src/i18n/locales/zh-Hant/ui.ts b/src/i18n/locales/zh-Hant/ui.ts index c2ecbc4d..e063cb15 100644 --- a/src/i18n/locales/zh-Hant/ui.ts +++ b/src/i18n/locales/zh-Hant/ui.ts @@ -509,7 +509,7 @@ const ui = { 'share.protect.error.long': '密碼最多 128 個字元。', 'share.protect.error.mismatch': '兩次輸入的密碼不一致。', 'share.protect.note.channel': '請透過與連結不同的途徑傳送密碼,不要放在同一則訊息裡。', - 'share.protect.note.lost': '密碼遺失後無法找回。這裡沒有伺服器,也沒有重設。', + 'share.protect.note.lost': 'Loop Studio 無法找回或重設密碼。', 'share.protect.note.strength': '連結的安全程度取決於密碼:拿到連結的人可以不受限制地一直猜下去。', 'share.protect.note.contract': 'Loop Studio 不保存也不傳輸密碼或金鑰。瀏覽器或密碼管理器可能會提議保存它。', 'share.protect.busy': '正在加密…', @@ -528,7 +528,7 @@ const ui = { 'share.open.notice.content': '密碼正確,但連結中沒有 Loop Studio 能開啟的圖表。請向傳送者索取新連結。', // issue #300 - the 0.17.0 release notes: password protection for share links 'whatsNew.v0170.protect': '分享連結可以用密碼保護:圖表會在連結內加密,只有持有密碼的人才能開啟。', - 'whatsNew.v0170.open': '受保護的連結會先詢問密碼,之後才顯示共享圖表的任何內容。密碼錯誤和連結損壞給出的是同一則提示。', + 'whatsNew.v0170.open': '受保護的連結會先詢問密碼,之後才顯示共享圖表的任何內容。密碼錯誤和建立後被更動的加密連結資料,給出的是同一則提示。', 'whatsNew.v0170.limits': '密碼遺失後無法找回,連結的安全程度取決於密碼。Loop Studio 不保存也不傳輸密碼或金鑰。', 'whatsNew.v0170.plain': '一般連結仍是預設選項,用法與之前相同。', } satisfies Record diff --git a/src/i18n/ptPtCopy.test.ts b/src/i18n/ptPtCopy.test.ts index 41645862..f533562c 100644 --- a/src/i18n/ptPtCopy.test.ts +++ b/src/i18n/ptPtCopy.test.ts @@ -50,6 +50,8 @@ const BANNED: ReadonlyArray = [ [/\bsaques?\b/i, 'levantamentos'], [/\bdetectar\b/i, 'detetar'], [/nova aba\b/i, 'novo separador'], + // issue #300 - the word for a password itself + [/\bsenhas?\b/i, 'palavra-passe'], ] describe('pt-PT copy — the region audit over pt-BR', () => { @@ -109,11 +111,50 @@ describe('pt-PT copy — the region audit over pt-BR', () => { // `app`, `gestor` not `gerenciador`, `a abrir` not `abrindo`. expect(DELTA).toHaveLength(255) // and it is a real audit, not a rewrite — most of the catalog agrees. - // The bound was a quarter until issue #300. The word for a password itself - // differs (`palavra-passe` / `senha`), so 28 of that issue's 33 strings - // differ for one reason, and the share reached 26%. The bound is 30% now; - // the count above is still the exact pin. - expect(DELTA.length).toBeLessThan(KEYS.length * 0.3) + // + // The password strings of issue #300 are counted apart. The word for a + // password itself differs (`palavra-passe` / `senha`, pinned in both + // directions by the banned-form scans below), so they differ by + // construction, and counting them into the share would say nothing about + // how much of the catalog was rewritten. Exactly these 28 of the 33 differ; + // the five that do not are the ones with no password word and no link word. + const isPasswordKey = (k: string) => /^(share\.protect\.|share\.open\.|share\.panel\.protected$|whatsNew\.v0170\.)/.test(k) + expect(KEYS.filter(isPasswordKey)).toHaveLength(33) + expect(DELTA.filter(isPasswordKey).sort()).toEqual([ + 'share.open.body', + 'share.open.busy', + 'share.open.error.auth', + 'share.open.notice.content', + 'share.open.notice.damaged', + 'share.open.notice.newer', + 'share.open.notice.title', + 'share.open.notice.unavailable', + 'share.panel.protected', + 'share.protect.busy', + 'share.protect.confirm', + 'share.protect.error.long', + 'share.protect.error.mismatch', + 'share.protect.error.short', + 'share.protect.hide', + 'share.protect.note.channel', + 'share.protect.note.contract', + 'share.protect.note.lost', + 'share.protect.note.strength', + 'share.protect.option', + 'share.protect.optionHelp', + 'share.protect.password', + 'share.protect.show', + 'share.protect.unavailable', + 'whatsNew.v0170.limits', + 'whatsNew.v0170.open', + 'whatsNew.v0170.plain', + 'whatsNew.v0170.protect', + ]) + // 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.length).toBeLessThan(rest.length / 4) }) // ------------------------------------------------- direction 1: nothing left diff --git a/src/i18n/ruCopy.test.ts b/src/i18n/ruCopy.test.ts index 43db2e71..2bfefa36 100644 --- a/src/i18n/ruCopy.test.ts +++ b/src/i18n/ruCopy.test.ts @@ -219,12 +219,14 @@ describe('ru copy — the first Cyrillic catalog', () => { 'задаётся', 'заменён', // issue #300: the password dialogs (`защищённая ссылка`, `надёжен`, - // `не передаёт`, `повреждённая ссылка`), here and below + // `не передаёт`) and the release note (`изменённые после создания`), + // here, above and below 'защищённая', 'защищённую', 'защищённый', 'идёт', 'изменён', + 'изменённые', 'моё', 'надёжен', 'надёжна', @@ -238,7 +240,6 @@ describe('ru copy — the first Cyrillic catalog', () => { 'передаёт', 'передаётся', 'перемещён', - 'повреждённая', 'подключён', 'прервёт', 'прошёл', diff --git a/src/i18n/thCopy.test.ts b/src/i18n/thCopy.test.ts index 2e4330e2..5a8cef9a 100644 --- a/src/i18n/thCopy.test.ts +++ b/src/i18n/thCopy.test.ts @@ -327,6 +327,7 @@ const LATIN_KEYS = [ 'catalog:share.open.notice.newer', 'catalog:share.open.notice.unavailable', 'catalog:share.protect.note.contract', + 'catalog:share.protect.note.lost', 'catalog:share.protect.unavailable', 'catalog:share.tooLarge', 'catalog:stateExpr.label.hint.empty', @@ -417,7 +418,7 @@ describe('a Latin run is declared, twice over — by key and by word', () => { it('the strings carrying a Latin run are exactly the declared ones', () => { expect(withLatin.slice().sort()).toEqual(LATIN_KEYS.slice().sort()) - expect(LATIN_KEYS).toHaveLength(162) + expect(LATIN_KEYS).toHaveLength(163) }) it('the Latin vocabulary is exactly the declared one', () => { diff --git a/src/i18n/viCopy.test.ts b/src/i18n/viCopy.test.ts index a905d26a..a7f68d1b 100644 --- a/src/i18n/viCopy.test.ts +++ b/src/i18n/viCopy.test.ts @@ -455,6 +455,7 @@ const ASCII_KEYS = [ 'catalog:share.open.notice.newer', 'catalog:share.open.notice.unavailable', 'catalog:share.protect.note.contract', + 'catalog:share.protect.note.lost', 'catalog:share.protect.unavailable', 'catalog:share.tooLarge', 'catalog:stateExpr.label.hint.empty', @@ -556,7 +557,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(182) + expect(ASCII_KEYS).toHaveLength(183) }) it('the kept vocabulary is exactly the declared one', () => { From c4ea6a78cc5eaad665d8a2f03a6a20df03d79473 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:10:37 +0900 Subject: [PATCH 7/8] test(playback): catch the first settle where it happens, not by a poll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #300 only in that its last local run exposed it; nothing here concerns share links, and no product code changes. `playback-choreography.spec.ts`, "§PB4.4 — at L0 the travelling dot is elided", was the one failure of the full local run at the previous commit. It asserts that exactly one step is committed. Under Play the store starts the next transition on the very next animation frame, so the state "one step committed, no transition in flight" lasts about one frame. The test polled every 30 ms and left its loop only when a sample landed inside that frame; when none did, it left one beat later with two steps committed. That is a race in the test, not a flake of the product. Measured on this machine, thirty runs of the test alone: 12 failed on a copy of `main` (3796589), 10 failed on this branch, with the same message. The test now installs a subscription on the simulation store before Play. The subscription sees `stepIndex === step0 + 1` with no transition in the same synchronous notification that creates that state, and pauses there, so the second transition never begins. The poll stays for what it was always good at: no moving dot at L0, the three phases in order, the L0 pulse during travel. The speed, the loop bound and the retry count are unchanged, and the test additionally asserts that the run is paused with no transition in flight. After the change, thirty runs each: 30 of 30 on a copy of `main` carrying only this file, 30 of 30 on this branch. The whole spec passes. --- e2e/playback-choreography.spec.ts | 35 +++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/e2e/playback-choreography.spec.ts b/e2e/playback-choreography.spec.ts index 93b2ad96..608998b6 100644 --- a/e2e/playback-choreography.spec.ts +++ b/e2e/playback-choreography.spec.ts @@ -493,11 +493,33 @@ test.describe('playback — Slice 2 choreography', () => { expect(z).toBeLessThan(0.45) const step0 = (await simState(page)).stepIndex + + // The first settle is caught WHERE IT HAPPENS, not by the poll below. Under + // Play the store starts the next transition on the very next animation + // frame, so "one step committed, no transition in flight" lasts about one + // frame; a 30 ms poll used to have to land inside it, and when it did not + // the loop left one beat later with two steps committed (measured: 12 of 30 + // runs on `main`). A store subscription, installed before Play, sees that + // state in the same synchronous notification that creates it and pauses + // there, so the second transition never begins and the state the + // assertions read is the settled one, for as long as they need it. + await page.evaluate((from) => { + const w = window as any + const sim = w.__loop.sim + w.__pb44FirstSettle = null + const unsubscribe = sim.subscribe((s: any) => { + if (s.stepIndex !== from + 1 || s.transition != null) return + unsubscribe() + sim.getState().pause() + w.__pb44FirstSettle = { stepIndex: s.stepIndex, at: performance.now() } + }) + }, step0) await call(page, 'play') // through the whole transition: never a moving `.pb-move` dot on the flowing edge const phases = new Set() let sawL0Pulse = false + let settled = false for (let i = 0; i < 90; i++) { const snap = await page.evaluate(() => { const edge = document.querySelector('.react-flow__edge[data-id="e_sp"]')! @@ -512,6 +534,7 @@ test.describe('playback — Slice 2 choreography', () => { phase: s.transition?.phase ?? null, tau: s.transition?.tau ?? null, step: s.stepIndex, + firstSettle: (window as any).__pb44FirstSettle != null, } }) expect(snap.dot).toBe(false) // the sub-pixel dot is never created at L0 @@ -520,13 +543,21 @@ test.describe('playback — Slice 2 choreography', () => { sawL0Pulse = true expect(snap.phase).toBe('travel') // the pulse only stands in for `travel` } - if (snap.step > step0 && snap.tau == null) break + if (snap.firstSettle) { + settled = true + expect(snap.tau).toBeNull() // nothing in flight: the pause held + break + } await page.waitForTimeout(30) } + expect(settled).toBe(true) // the first settle was seen by the subscription expect([...phases].sort()).toEqual(['arrive', 'depart', 'travel']) // ordered beats still ran expect(sawL0Pulse).toBe(true) // the travel stand-in showed - expect((await simState(page)).stepIndex).toBe(step0 + 1) // settle still committed exactly one step + const end = await simState(page) + expect(end.stepIndex).toBe(step0 + 1) // settle still committed exactly one step + expect(end.status).toBe('paused') // and the run stopped there + expect(end.fromStep).toBeNull() // no second transition began await call(page, 'pause') }) From 5ee4272bc2a44f138a2aebeebf35a60b59024529 Mon Sep 17 00:00:00 2001 From: Hanrim <148833226+MerciHanrim@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:13:28 +0900 Subject: [PATCH 8/8] feat(share): a Copy button on the phone's share result sheet Part of #300. Found on an iPhone while checking this pull request's preview. The phone's result sheet copied the link once, automatically, and offered nothing after that: a status line and a read-only field. A clipboard overwritten since, or a browser that refused the first write, left a long link to be selected by hand. With a protected link the way back is worse than for a plain one, because making it again means typing the password twice. The sheet now has a button under the field. It says `Copy again` after the automatic copy worked and `Copy` when it did not. It copies the link the sheet is showing and nothing else: no link is made again, and the sheet never holds a password. On success the status line says the link was copied, in a live region whose text node is replaced so it is announced each time. Where the clipboard refuses, the field takes focus with the whole link selected, and the status keeps saying to copy it by hand. A plain and a protected link use the same button. Desktop and phone now call one function, `copyShareLink`, for every copy of a link. The strings are the desktop panel's existing ones; no translation is added. Keeping the link after the sheet is closed is not part of this change. Tests, in the phone project: a plain and a protected link are each copied once automatically and three more times by the button, with the same link every time, no second key derivation and no password in any write; a refused first copy shows `Copy`, a second refusal selects the whole link, and a working clipboard then copies and says so; at 320 and 390 px the button is inside the sheet and the screen, at least 44 px tall, and what is under its centre is the button, not the run bar. --- e2e/mobile.spec.ts | 125 ++++++++++++++++++++++ src/components/mobile/MobileMoreMenu.tsx | 59 ++++++---- src/components/toolbar/useShareSurface.ts | 29 ++--- src/index.css | 5 + src/ui/shareAction.ts | 15 +++ 5 files changed, 190 insertions(+), 43 deletions(-) diff --git a/e2e/mobile.spec.ts b/e2e/mobile.spec.ts index 4cb3c8d7..32f564ca 100644 --- a/e2e/mobile.spec.ts +++ b/e2e/mobile.spec.ts @@ -429,6 +429,131 @@ test.describe('mobile view/run — Slice 2 chrome', () => { await expect(page.locator('.react-flow__node')).toHaveCount(nodes) }) + // issue #300 - the automatic copy happens once; the button is the way back to + // a link that takes a password typed twice to make again + test.describe('the result sheet has a Copy button', () => { + const PASSWORD = 'phone copy password 9' // never a real one + const urlField = (page: Page) => page.locator('.sheet .share-pop__url') + const copyButton = (page: Page) => page.locator('.sheet [data-share-copy]') + const status = (page: Page) => page.locator('.sheet .share-pop__status[role="status"]') + const clip = (page: Page) => page.evaluate(() => (window as any).__clipWrites as string[]) + + /** every write is recorded; `window.__clipMode = 'fail'` makes the next ones throw */ + async function stubClipboard(page: Page): Promise { + await page.addInitScript(() => { + ;(window as any).__clipWrites = [] + ;(window as any).__derives = 0 + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: async (t: string) => { + if ((window as any).__clipMode === 'fail') throw new Error('denied') + ;(window as any).__clipWrites.push(t) + }, + }, + }) + if (crypto.subtle) { + const real = crypto.subtle.deriveKey.bind(crypto.subtle) + crypto.subtle.deriveKey = ((...a: Parameters) => { + ;(window as any).__derives++ + return real(...a) + }) as SubtleCrypto['deriveKey'] + } + }) + } + async function makeLink(page: Page, protect: boolean): Promise { + await more(page).click() + await sheet(page, 'More').locator('.sheet__row', { hasText: 'Share link' }).click() + const dlg = page.locator('.mcdlg--share[data-share-create]') + if (protect) { + await dlg.locator('[data-share-protect="option"]').check() + await dlg.locator('[data-share-protect="password"]').fill(PASSWORD) + await dlg.locator('[data-share-protect="confirm"]').fill(PASSWORD) + } + await dlg.getByRole('button', { name: 'Create link' }).click() + await expect(urlField(page)).toBeVisible() + return urlField(page).inputValue() + } + + for (const [kind, protect, prefix] of [['plain', false, '#g1='], ['protected', true, '#p1=']] as const) { + test(`a ${kind} link: copied once automatically, then again by the button - the same link, nothing made twice, no password`, async ({ page }) => { + await stubClipboard(page) + await loadDiagram(page) + const url = await makeLink(page, protect) + expect(url).toContain(prefix) + expect(await clip(page)).toEqual([url]) + const derivesAfterMaking = await page.evaluate(() => (window as any).__derives as number) + + // an accessible name, and it says "again" because the first copy worked + await expect(copyButton(page)).toHaveAccessibleName('Copy again') + await expect(status(page)).toHaveText('Link copied to the clipboard.') + + for (let n = 2; n <= 4; n++) { + await copyButton(page).click() + await expect.poll(async () => (await clip(page)).length).toBe(n) + } + expect(new Set(await clip(page))).toEqual(new Set([url])) // the link, four times, and nothing else + expect(await urlField(page).inputValue()).toBe(url) // the link did not change + expect(await page.evaluate(() => (window as any).__derives as number)).toBe(derivesAfterMaking) // and none was made again + await expect(status(page)).toHaveText('Link copied to the clipboard.') + for (const text of await clip(page)) expect(text).not.toContain(PASSWORD) + }) + } + + test('the first copy refused: the button says Copy, a second refusal selects the whole link, then it copies', async ({ page }) => { + await stubClipboard(page) + await loadDiagram(page) + await page.evaluate(() => { + ;(window as any).__clipMode = 'fail' + }) + const url = await makeLink(page, true) + expect(await clip(page)).toEqual([]) + await expect(status(page)).toHaveText('Copy this link:') + await expect(copyButton(page)).toHaveAccessibleName('Copy') + + // still refused: the field is selected whole, for a manual copy + await copyButton(page).click() + await expect + .poll(() => urlField(page).evaluate((el: HTMLInputElement) => document.activeElement === el && el.selectionStart === 0 && el.selectionEnd === el.value.length)) + .toBe(true) + await expect(status(page)).toHaveText('Copy this link:') + + // the clipboard works again: the button copies, and says so + await page.evaluate(() => { + ;(window as any).__clipMode = 'ok' + }) + await copyButton(page).click() + await expect(status(page)).toHaveText('Link copied to the clipboard.') + await expect(copyButton(page)).toHaveAccessibleName('Copy again') + expect(await clip(page)).toEqual([url]) + }) + + for (const size of [ + { width: 320, height: 568 }, + { width: 390, height: 844 }, + ]) { + test(`at ${size.width} px the button is inside the sheet and the screen, and the run bar does not cover it`, async ({ page }) => { + await page.setViewportSize(size) + await stubClipboard(page) + await loadDiagram(page) + await makeLink(page, true) + const button = copyButton(page) + await button.scrollIntoViewIfNeeded() + const box = (await button.boundingBox())! + const sheetBox = (await page.locator('.sheet').filter({ has: page.locator('[data-share-copy]') }).boundingBox())! + expect(rectInside(box, size.width, size.height, 1)).toBe(true) + expect(box.x).toBeGreaterThanOrEqual(sheetBox.x) + expect(box.x + box.width).toBeLessThanOrEqual(sheetBox.x + sheetBox.width + 1) + expect(box.y + box.height).toBeLessThanOrEqual(sheetBox.y + sheetBox.height + 1) + expect(box.height).toBeGreaterThanOrEqual(44) // a touch target + // what a finger at its centre would hit is the button itself + const hit = await page.evaluate(([x, y]) => document.elementFromPoint(x!, y!)?.closest('[data-share-copy]') != null, [box.x + box.width / 2, box.y + box.height / 2]) + expect(hit).toBe(true) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) // no sideways scroll + }) + } + }) + test('PWA update bar + an open sheet: Close, Update and Play are each visible and clickable', async ({ page }) => { await loadDiagram(page) // dev has no service worker — poke the store so `.pwa-update` renders diff --git a/src/components/mobile/MobileMoreMenu.tsx b/src/components/mobile/MobileMoreMenu.tsx index de173a53..32f14289 100644 --- a/src/components/mobile/MobileMoreMenu.tsx +++ b/src/components/mobile/MobileMoreMenu.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, type RefObject } from 'react' +import { useCallback, useRef, useState, type RefObject } from 'react' import { useReactFlow } from '@xyflow/react' import { FEEDBACK_URL } from '../../feedback' import { openTemplate } from '../../i18n/templateLabels' @@ -20,7 +20,7 @@ import { } from '../../store/workspaceIO' import { downloadText } from '../../ui/download' import { exportProjectRevision, makeProposal } from '../../ui/revisionActions' -import { type ProtectedShareLinkResult, prepareProtectedShareLink, prepareShareLink, shareKb } from '../../ui/shareAction' +import { type ProtectedShareLinkResult, copyShareLink, prepareProtectedShareLink, prepareShareLink, shareKb } from '../../ui/shareAction' import { useTourStore } from '../../store/tourStore' import { useWhatsNewStore } from '../../store/whatsNewStore' import { useHintStore, useTier3Ready, useLargeGraphInteractionGate } from '../../store/hintStore' @@ -112,7 +112,10 @@ export function MobileMoreMenu({ closeOverlay('more') } - const [sharePanel, setSharePanel] = useState<{ url: string; copied: boolean; protected?: boolean } | null>(null) + // `copies` counts the successful copies of THIS link, so the status line is + // announced again each time the button below copies it again + const [sharePanel, setSharePanel] = useState<{ url: string; copied: boolean; copies: number; protected?: boolean } | null>(null) + const shareUrlRef = useRef(null) const [shareConfirm, setShareConfirm] = useState(false) const [shareBusy, setShareBusy] = useState(false) const [authorOpen, setAuthorOpen] = useState(false) @@ -143,14 +146,8 @@ export function MobileMoreMenu({ window.alert(t('share.noBase')) return } - let copied = false - try { - await navigator.clipboard.writeText(result.url) - copied = true - } catch { - copied = false - } - setSharePanel({ url: result.url, copied }) + const copied = await copyShareLink(result.url) + setSharePanel({ url: result.url, copied, copies: copied ? 1 : 0 }) openOverlay('share') } finally { setShareBusy(false) @@ -166,15 +163,9 @@ export function MobileMoreMenu({ try { const result = await prepareProtectedShareLink(exportJSON({ ...useMcStore.getState().config }), password) if (result.status !== 'ok') return result - let copied = false - try { - await navigator.clipboard.writeText(result.url) - copied = true - } catch { - copied = false - } + const copied = await copyShareLink(result.url) setShareConfirm(false) - setSharePanel({ url: result.url, copied, protected: true }) + setSharePanel({ url: result.url, copied, copies: copied ? 1 : 0, protected: true }) openOverlay('share') return result } finally { @@ -182,6 +173,24 @@ export function MobileMoreMenu({ } } + // issue #300 — the result sheet's Copy button. The automatic copy above + // happens once; a clipboard that was overwritten since, or a browser that + // refused that first write, left no way back to a link that takes a password + // typed twice to make again. This copies the SAME link (never a password, the + // sheet never has one) and nothing is created again. Where the clipboard + // refuses, the field is selected whole for a manual copy. + const copyShareAgain = async () => { + const panel = sharePanel + if (!panel) return + const ok = await copyShareLink(panel.url) + setSharePanel((p) => (p && p.url === panel.url ? { ...p, copied: ok, copies: ok ? p.copies + 1 : p.copies } : p)) + if (!ok) { + const el = shareUrlRef.current + el?.focus() + el?.setSelectionRange(0, el.value.length) // `select()` alone does not select on iOS + } + } + const doLoadTemplate = (id: string) => { const tpl = TEMPLATES.find((x) => x.id === id) if (!tpl) return @@ -575,8 +584,10 @@ export function MobileMoreMenu({ }} returnFocusTo={backToMore} > -
- {sharePanel.copied ? t('share.panel.copied') : t('share.panel.copyThis')} + {/* a live region: the keyed child is replaced on every successful copy, so + "copied" is said again when the button copies the same link again */} +
+ {sharePanel.copied ? t('share.panel.copied') : t('share.panel.copyThis')}
{sharePanel.protected ? (
@@ -584,12 +595,18 @@ export function MobileMoreMenu({
) : null} e.currentTarget.select()} /> +
+ +
) } diff --git a/src/components/toolbar/useShareSurface.ts b/src/components/toolbar/useShareSurface.ts index 32b85192..82059e0b 100644 --- a/src/components/toolbar/useShareSurface.ts +++ b/src/components/toolbar/useShareSurface.ts @@ -1,7 +1,7 @@ import { useRef, useState } from 'react' import { useGraphStore } from '../../store/graphStore' import { recommendedRunConfigForExport } from '../../store/mcStore' -import { type ProtectedShareLinkResult, prepareProtectedShareLink, prepareShareLink, shareKb } from '../../ui/shareAction' +import { type ProtectedShareLinkResult, copyShareLink, prepareProtectedShareLink, prepareShareLink, shareKb } from '../../ui/shareAction' import { useT } from '../../i18n' export type ShareSurface = @@ -54,13 +54,8 @@ export function useShareSurface() { } const url = result.url - let copied = false - try { - await navigator.clipboard.writeText(url) - copied = true - } catch { - copied = false // Clipboard API missing or denied — the field below is the fallback - } + // Clipboard API missing or denied ⇒ false: the field below is the fallback + const copied = await copyShareLink(url) setSurface({ phase: 'panel', url, copied }) } finally { busyRef.current = false @@ -79,13 +74,7 @@ export function useShareSurface() { try { const result = await prepareProtectedShareLink(exportJSON(recommendedRunConfigForExport()), password) if (result.status !== 'ok') return result - let copied = false - try { - await navigator.clipboard.writeText(result.url) - copied = true - } catch { - copied = false - } + const copied = await copyShareLink(result.url) setSurface({ phase: 'panel', url: result.url, copied, protected: true }) return result } finally { @@ -96,13 +85,9 @@ export function useShareSurface() { const retryCopy = async (): Promise => { if (surface?.phase !== 'panel') return false - try { - await navigator.clipboard.writeText(surface.url) - setSurface({ ...surface, copied: true }) - return true - } catch { - return false - } + const ok = await copyShareLink(surface.url) + if (ok) setSurface({ ...surface, copied: true }) + return ok } const closePanel = () => setSurface(null) diff --git a/src/index.css b/src/index.css index 79870dbb..7b424f0e 100644 --- a/src/index.css +++ b/src/index.css @@ -5700,6 +5700,11 @@ input[type='number']::-webkit-inner-spin-button { .sheet .share-pop__url { font-size: 12px; } + /* issue #300 - the result sheet's Copy button: one full-width touch target */ + .sheet .share-pop__row .btn { + flex: 1; + min-height: 44px; + } /* issue #300 - a password field under 16px makes iOS zoom the page on focus. The base rules for these two sit at the END of this file, after this block, so each override here carries one more class than its base rule. */ diff --git a/src/ui/shareAction.ts b/src/ui/shareAction.ts index 32ee313e..f755e024 100644 --- a/src/ui/shareAction.ts +++ b/src/ui/shareAction.ts @@ -44,6 +44,21 @@ export async function prepareShareLink(doc: string): Promise { return url == null ? { status: 'no-base' } : { status: 'ok', url } } +/** + * Put a share link on the clipboard. Resolves `false` where the Clipboard API + * is missing or refuses; the caller then leaves the link in its field for a + * manual copy (§U7). The one copy path of desktop and phone, for a plain and a + * protected link alike - and it is only ever handed the LINK, never a password. + */ +export async function copyShareLink(url: string): Promise { + try { + await navigator.clipboard.writeText(url) + return true + } catch { + return false + } +} + export type ProtectedShareLinkResult = | ShareLinkResult | { status: 'unavailable' } // no Web Crypto on this page