diff --git a/README.md b/README.md
index 2b402e1..65e580f 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ flowchart TD
**1. 创建房间** 打开[创建页面](https://freecode100year.github.io/htmlroom/),直接点「创建房间」。可选:
- **房间标题**:最多 60 字,显示在浏览器标签页和页面顶部;不填就是「聚众加密聊天房间」。
-- **口令**:相当于门上再加一把锁。建议点「随机生成口令」。
+- **口令**:相当于门上再加一把锁。建议点「随机生成口令」:会生成一串好念好抄的口令(如 `K7QM-3XRP-9DTA-HWVE-N4BC`),直接显示出来,点「复制口令」即可复制;房间建好后口令仍会显示在结果里。
**2. 发给朋友** 下载 `index.html` 发给朋友,或复制房间链接。设了口令的话,**口令请换一种方式告诉对方**(比如文件用微信发,口令打电话说)。
diff --git a/browser-tests/htmlroom.mjs b/browser-tests/htmlroom.mjs
index 9b23cbe..b771692 100644
--- a/browser-tests/htmlroom.mjs
+++ b/browser-tests/htmlroom.mjs
@@ -29,7 +29,7 @@ try{
for(const c of [a,b])assert.deepEqual(await c.cookies(),[]);
for(const p of [maker,file])assert.deepEqual(await p.evaluate(async()=>[localStorage.length,sessionStorage.length,(await indexedDB.databases()).length]),[0,0,0]);
const bad=await a.newPage();let connected=0;bad.on('websocket',()=>connected++);await bad.goto(base+'/hr.html#hr1=invalid');await bad.getByText(/房间链接打不开/).waitFor();assert.equal(connected,0);
- const wrapped=await a.newPage();await wrapped.goto(base+'/hr.html');await wrapped.locator('#mk-gen').click();assert.equal((await wrapped.locator('#mk-pass').inputValue()).length,64);await wrapped.locator('#mk-make').click();await wrapped.locator('#mk-out').waitFor({state:'visible'});
+ const wrapped=await a.newPage();await wrapped.goto(base+'/hr.html');await wrapped.locator('#mk-gen').click();assert.match(await wrapped.locator('#mk-pass').inputValue(),/^[A-HJKMNP-Z2-9]{4}(-[A-HJKMNP-Z2-9]{4}){4}$/);assert.equal(await wrapped.locator('#mk-pass').getAttribute('type'),'text');await wrapped.locator('#mk-make').click();await wrapped.locator('#mk-out').waitFor({state:'visible'});
const encoded=new URL(await wrapped.locator('#mk-link').inputValue()).hash.slice(5);const protectedConfig=JSON.parse(Buffer.from(encoded,'base64url').toString());assert.equal(protectedConfig.encrypted,true);assert.equal('master'in protectedConfig,false);
console.log(JSON.stringify({suite:'integrated hosted maker/Fragment + file interop',checks:9,result:'pass',browser:browser.version(),consoleErrors:0,thirdPartyRequests:0}));
}finally{await browser?.close();server?.kill();await rm(dir,{recursive:true,force:true});}
diff --git a/browser-tests/password-ui.mjs b/browser-tests/password-ui.mjs
new file mode 100644
index 0000000..6f458bd
--- /dev/null
+++ b/browser-tests/password-ui.mjs
@@ -0,0 +1,68 @@
+// The generated password is readable and copyable, survives creating the room,
+// and opens it; the room page can show what is typed. Both on the site and in
+// a local file (where the copy may fall back to select-and-copy).
+import { chromium } from 'playwright';
+import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { pathToFileURL } from 'node:url';
+import assert from 'node:assert/strict';
+import { render } from '../scripts/build-html-room.mjs';
+import { configFromRoomHtml } from '../html-room/template.js';
+import { unlock } from '../html-room/crypto.js';
+import { nodeRelay } from '../test/fixtures/mesh-relays.mjs';
+
+const FORMAT = /^[A-HJKMNP-Z2-9]{4}(-[A-HJKMNP-Z2-9]{4}){4}$/;
+const dir = await mkdtemp(tmpdir() + '/hr-pw-');
+const relay = await nodeRelay();
+let browser;
+try {
+ browser = await chromium.launch({ headless: true });
+ const ctx = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] });
+ const errors = []; const watch = p => { p.on('pageerror', e => errors.push(e.message)); return p; };
+ const page = watch(await ctx.newPage());
+ await page.goto(`http://127.0.0.1:${relay.port}/`);
+ await page.locator('#mk-gen').click();
+ const pw = await page.locator('#mk-pass').inputValue();
+ assert.match(pw, FORMAT, 'readable groups, no look-alike characters');
+ assert.equal(await page.locator('#mk-pass').getAttribute('type'), 'text', 'shown after generating');
+ assert.equal(await page.locator('#mk-show').textContent(), '隐藏');
+ await page.locator('#mk-copy-pass').click();
+ assert.equal(await page.evaluate(() => navigator.clipboard.readText()), pw, 'copied to the clipboard');
+ await page.locator('#mk-show').click();
+ assert.equal(await page.locator('#mk-pass').getAttribute('type'), 'password');
+ await page.locator('#mk-show').click();
+ await page.locator('#mk-make').click(); await page.locator('#mk-out').waitFor({ state: 'visible' });
+ assert.equal(await page.locator('#mk-pass-row').isVisible(), true, 'the password stays readable with the result');
+ assert.equal(await page.locator('#mk-pass-out').inputValue(), pw);
+ await page.evaluate(() => navigator.clipboard.writeText('-'));
+ await page.locator('#mk-pass-copy').click();
+ assert.equal(await page.evaluate(() => navigator.clipboard.readText()), pw);
+ const dl = page.waitForEvent('download'); await page.locator('#mk-download').click();
+ const roomPath = dir + '/room.html'; await (await dl).saveAs(roomPath);
+ const config = configFromRoomHtml(await readFile(roomPath, 'utf8'));
+ assert.equal(config.encrypted, true);
+ assert.equal((await unlock(config, pw)).roomId, config.roomId, 'the shown password opens the room');
+
+ // The room page: show what is typed, then enter with the copied password.
+ const room = watch(await ctx.newPage()); await room.goto(pathToFileURL(roomPath).href);
+ await room.locator('#password').fill(pw);
+ assert.equal(await room.locator('#password').getAttribute('type'), 'password');
+ await room.locator('#pw-show').click();
+ assert.equal(await room.locator('#password').getAttribute('type'), 'text');
+ await room.locator('#enter').click();
+ await room.locator('#relay-status').filter({ hasText: relay.url }).waitFor({ timeout: 15000 });
+
+ // A local creator without clipboard permission: copy falls back, and the text is left selected.
+ const blankPath = dir + '/creator.html'; await writeFile(blankPath, (await render({}, [])).html);
+ const local = watch(await (await browser.newContext()).newPage()); await local.goto(pathToFileURL(blankPath).href);
+ await local.locator('#mk-gen').click();
+ const pw2 = await local.locator('#mk-pass').inputValue();
+ assert.match(pw2, FORMAT); assert.notEqual(pw2, pw);
+ await local.locator('#mk-copy-pass').click();
+ await local.locator('#mk-error').filter({ hasText: /口令已复制|口令已选中/ }).waitFor();
+ assert.equal(await local.evaluate(() => getSelection().toString() || document.activeElement.value.slice(document.activeElement.selectionStart, document.activeElement.selectionEnd)), pw2, 'selected for copying by hand');
+ assert.deepEqual(errors, []);
+ console.log('PASS: generated password readable and copyable, kept on the result, opens the room; show/hide on both forms; local-file copy fallback');
+} finally {
+ await browser?.close(); await relay.stop(); await rm(dir, { recursive: true, force: true });
+}
diff --git a/dist/_headers b/dist/_headers
index 91c1b09..f90e0bb 100644
--- a/dist/_headers
+++ b/dist/_headers
@@ -1,5 +1,5 @@
/*
- Content-Security-Policy: default-src 'none'; script-src 'sha256-2JtQBGQ1g3RysBYMwJmcKy+WFVqLIDDZxJLfYInhifQ='; style-src 'sha256-+cH4mX6DQpXWnU8EWkUQE30KyFp1e6rCFFdGd+i+pzA='; connect-src 'self' wss: ws://localhost:* ws://127.0.0.1:*; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
+ Content-Security-Policy: default-src 'none'; script-src 'sha256-aNnjLm65Zqk0gxUuDbkzb8IA4rm24tWrD4YbtohyyZ8='; style-src 'sha256-kDQ4uvzydd/bYRgj2CH45MTiA02XvOWw5A9uc4FMls8='; connect-src 'self' wss: ws://localhost:* ws://127.0.0.1:*; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
Cache-Control: no-store
NEL: {"max_age":0}
Referrer-Policy: no-referrer
diff --git a/dist/hr-template.html b/dist/hr-template.html
index 621aaca..6aad7e7 100644
--- a/dist/hr-template.html
+++ b/dist/hr-template.html
@@ -28,8 +28,8 @@
`)}});for(let f of["exit","SIGINT","SIGTERM"])process.once(f,()=>c.kill())}let Z=!1;for(let t of["SIGTERM","SIGINT"])process.on(t,()=>{Z&&process.exit(0),Z=!0,m.close();for(let c of S)c.close(1012,"restart");setTimeout(()=>process.exit(0),S.size?500:0)});return m}typeof require<"u"&&require.main===module&&ke((0,L.readFileSync)(__filename,"utf8"),require("node:path").basename(__filename));0&&(module.exports={serve});
/*
-->
-
聚众加密聊天房间聚众加密聊天房间
正在启动……如果一直停在这里,请看下面的说明。
页面没有启动?微信、QQ 或手机「文件」App 的预览打不开聊天室,请用 Safari、Chrome 等浏览器打开这个文件。
生成新文件:换中继或改标题(发给大家)
中继是帮大家传消息的服务器,它看不到聊天内容。填上新地址,会生成一份新的房间文件:还是同一个房间,口令也不变。把新文件发给所有人,大家打开就自动用新中继。
例如 wss://relay.example.com;去中心化中继写成 bc+wss://…、nostr+wss://… 或 mqtt+wss://…/mqtt。
下载新的房间文件
创建一个新房间(新的钥匙,可设新口令)
+聚众加密聊天房间聚众加密聊天房间
正在启动……如果一直停在这里,请看下面的说明。
页面没有启动?微信、QQ 或手机「文件」App 的预览打不开聊天室,请用 Safari、Chrome 等浏览器打开这个文件。
生成新文件:换中继或改标题(发给大家)
中继是帮大家传消息的服务器,它看不到聊天内容。填上新地址,会生成一份新的房间文件:还是同一个房间,口令也不变。把新文件发给所有人,大家打开就自动用新中继。
例如 wss://relay.example.com;去中心化中继写成 bc+wss://…、nostr+wss://… 或 mqtt+wss://…/mqtt。
下载新的房间文件
创建一个新房间(新的钥匙,可设新口令)
diff --git a/dist/hr.html b/dist/hr.html
index 621aaca..6aad7e7 100644
--- a/dist/hr.html
+++ b/dist/hr.html
@@ -28,8 +28,8 @@
`)}});for(let f of["exit","SIGINT","SIGTERM"])process.once(f,()=>c.kill())}let Z=!1;for(let t of["SIGTERM","SIGINT"])process.on(t,()=>{Z&&process.exit(0),Z=!0,m.close();for(let c of S)c.close(1012,"restart");setTimeout(()=>process.exit(0),S.size?500:0)});return m}typeof require<"u"&&require.main===module&&ke((0,L.readFileSync)(__filename,"utf8"),require("node:path").basename(__filename));0&&(module.exports={serve});
/*
-->
-聚众加密聊天房间聚众加密聊天房间
正在启动……如果一直停在这里,请看下面的说明。
页面没有启动?微信、QQ 或手机「文件」App 的预览打不开聊天室,请用 Safari、Chrome 等浏览器打开这个文件。
生成新文件:换中继或改标题(发给大家)
中继是帮大家传消息的服务器,它看不到聊天内容。填上新地址,会生成一份新的房间文件:还是同一个房间,口令也不变。把新文件发给所有人,大家打开就自动用新中继。
例如 wss://relay.example.com;去中心化中继写成 bc+wss://…、nostr+wss://… 或 mqtt+wss://…/mqtt。
下载新的房间文件
创建一个新房间(新的钥匙,可设新口令)
+聚众加密聊天房间聚众加密聊天房间
正在启动……如果一直停在这里,请看下面的说明。
页面没有启动?微信、QQ 或手机「文件」App 的预览打不开聊天室,请用 Safari、Chrome 等浏览器打开这个文件。
生成新文件:换中继或改标题(发给大家)
中继是帮大家传消息的服务器,它看不到聊天内容。填上新地址,会生成一份新的房间文件:还是同一个房间,口令也不变。把新文件发给所有人,大家打开就自动用新中继。
例如 wss://relay.example.com;去中心化中继写成 bc+wss://…、nostr+wss://… 或 mqtt+wss://…/mqtt。
下载新的房间文件
创建一个新房间(新的钥匙,可设新口令)
diff --git a/docs/html-room/release-integrity.json b/docs/html-room/release-integrity.json
index 31038a1..0ebbab8 100644
--- a/docs/html-room/release-integrity.json
+++ b/docs/html-room/release-integrity.json
@@ -1,16 +1,16 @@
{
"version": 2,
- "stage": "room-title-htmlroom",
+ "stage": "visible-password-htmlroom",
"esbuild": "0.28.2",
"artifacts": {
"html-room-template.html": {
- "bytes": 141314,
- "sha256": "5a3f6fff21b29210b2744c91ab1e83cc67c668ee91e5008cc2e8e5b88edfbc70",
+ "bytes": 144598,
+ "sha256": "371e2db7d0a7b88a67d3999745f2041e7b96fac568d12e0e232765704eb1c347",
"csp": "([^"
}
},
- "scriptHashBase64": "2JtQBGQ1g3RysBYMwJmcKy+WFVqLIDDZxJLfYInhifQ=",
- "scriptHashHex": "d89b50046435837472b0160cc0999c2b2f96155a8b2030d9c492df6089e189f4",
- "styleHashBase64": "+cH4mX6DQpXWnU8EWkUQE30KyFp1e6rCFFdGd+i+pzA=",
+ "scriptHashBase64": "aNnjLm65Zqk0gxUuDbkzb8IA4rm24tWrD4YbtohyyZ8=",
+ "scriptHashHex": "68d9e32e6eb966a93483152e0db9336fc200e2b9b6e2d5ab0f861bb68872c99f",
+ "styleHashBase64": "kDQ4uvzydd/bYRgj2CH45MTiA02XvOWw5A9uc4FMls8=",
"serverCommentSha256": "971a532473d0c7d039dd0617391a736bd4057fab488e2249d612188a340195fe"
}
diff --git a/html-room/client.js b/html-room/client.js
index 4f55d2b..320a94c 100644
--- a/html-room/client.js
+++ b/html-room/client.js
@@ -1,4 +1,5 @@
import { unlock, validate, hex, sessionRelays } from './crypto.js';
+import { showToggle } from './ui.js';
import { Session } from './session.js';
import { RelayConnection } from './relay-connection.js';
import { MeshConnection } from './mesh.js';
@@ -21,6 +22,7 @@ if(!globalThis.crypto?.subtle || !globalThis.WebSocket || !globalThis.TextEncode
else if(config.version){
try{validate(config);status(config.encrypted?'请输入口令,然后点「进入房间」。':'点「进入房间」开始聊天。');el('password').disabled=false;el('enter').disabled=false;}catch{status('房间文件有问题,请让对方重新发一份。');el('enter').disabled=true;}
}else{status('这是空白模板,请先在创建页面里创建房间。');el('enter').disabled=true;}
+showToggle(el('password'),el('pw-show'));
const custom=allowCustom && config.version && config.custom===true;
if(custom){el('custom-relay').hidden=false;el('relay-input').value=config.relays.join(' ');}
el('unlock').addEventListener('submit',async e=>{e.preventDefault();if(busy||Date.now() parseInt(h, 16));
}
export const random = n => crypto.getRandomValues(new Uint8Array(n));
+// A random password people can read out and type: 20 characters from 31 that
+// are hard to confuse (no I, L, O, 0, 1), in groups of four. About 99 bits.
+const PW_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
+export function readablePassword(groups = 5) {
+ const out = [];
+ while (out.length < groups * 4) {
+ for (const b of random(32)) if (b < 248 && out.length < groups * 4) out.push(PW_ALPHABET[b % 31]); // 248 = 8 × 31: no bias
+ }
+ return out.join('').match(/.{4}/g).join('-');
+}
export async function derive(master) {
const raw = typeof master === 'string' ? bytes(master, 32) : master;
if (!(raw instanceof Uint8Array) || raw.length !== 32) throw Error('房间密钥长度错误');
diff --git a/html-room/file.js b/html-room/file.js
index 3672def..2721074 100644
--- a/html-room/file.js
+++ b/html-room/file.js
@@ -6,7 +6,8 @@
// exact script and style bytes this page runs with (html-room/template.js),
// so they are byte-identical to a release build and pass verify-room-file.
import { boot } from './client.js';
-import { makeConfig, relayList, validate, validateBlank, hex, random, DEFAULT_TITLE } from './crypto.js';
+import { makeConfig, relayList, validate, validateBlank, readablePassword, DEFAULT_TITLE } from './crypto.js';
+import { copyText, showToggle } from './ui.js';
import { configFromHash, fragmentFor } from './transport.js';
import { retargetHtml, htmlFor, withRelays, configFromRoomHtml } from './template.js';
@@ -51,7 +52,15 @@ function enableMaker(defaults, { open, siteTitle }) {
if (open) { el('new-room').open = true; el('new-room').querySelector('summary').hidden = true; }
el('mk-relay').value = defaults.join(' ');
if (!defaults.length) el('mk-advanced').open = true;
- el('mk-gen').addEventListener('click', () => { el('mk-pass').value = hex(random(32)); });
+ const pass = showToggle(el('mk-pass'), el('mk-show'));
+ el('mk-pass').addEventListener('input', () => { el('mk-copy-pass').hidden = !el('mk-pass').value; });
+ // A generated password is shown, so it can be read out and copied.
+ el('mk-gen').addEventListener('click', () => {
+ el('mk-pass').value = readablePassword(); pass.show(true); el('mk-copy-pass').hidden = false;
+ fail('已生成口令,显示在上面。可以点「复制口令」。');
+ });
+ el('mk-copy-pass').addEventListener('click', () => copyText(el('mk-pass')).then(ok => fail(ok ? '口令已复制。请换一种方式告诉朋友,别和文件一起发。' : '复制失败:口令已选中,请手动复制。')));
+ el('mk-pass-copy').addEventListener('click', () => copyText(el('mk-pass-out')).then(ok => fail(ok ? '口令已复制。请换一种方式告诉朋友,别和文件一起发。' : '复制失败:口令已选中,请手动复制。')));
// Classic relays of this project also forward decentralized rooms on /bc/.
el('mk-mesh').addEventListener('click', () => {
const list = el('mk-relay').value.trim().split(/[\s,]+/).filter(Boolean).map(r => /^wss?:\/\//.test(r) ? `bc+${r.replace(/\/+$/, '')}/bc/` : r);
@@ -65,7 +74,10 @@ function enableMaker(defaults, { open, siteTitle }) {
if (password && new TextEncoder().encode(password).length > 1024) throw Error('口令太长了');
const next = await makeConfig(relays, password || undefined, { custom: el('mk-custom').checked, title: el('mk-title').value });
offer('mk-download', await htmlFor(next, own));
- el('mk-out').hidden = false; el('mk-pass').value = '';
+ el('mk-out').hidden = false;
+ // The password stays readable with the result: losing it loses the room.
+ el('mk-pass-out').value = password; el('mk-pass-row').hidden = !password;
+ el('mk-pass').value = ''; pass.show(false); el('mk-copy-pass').hidden = true;
// A link only works on a hosted creator, which can open any room.
if (hosted) { el('mk-link').value = location.origin + location.pathname + fragmentFor(next); el('mk-link-row').hidden = false; }
else { el('mk-link').value = ''; el('mk-link-row').hidden = true; }
@@ -73,7 +85,7 @@ function enableMaker(defaults, { open, siteTitle }) {
} catch (e) { fail(cjk(e) ? `创建失败:${e.message}` : '创建失败,请检查中继地址后再试。'); el('mk-advanced').open = true; }
finally { el('mk-make').disabled = false; }
});
- el('mk-copy').addEventListener('click', () => navigator.clipboard?.writeText(el('mk-link').value).then(() => fail('已复制。链接就是钥匙,请只发给要聊天的人。'), () => fail('复制失败,请手动选中链接复制。')));
+ el('mk-copy').addEventListener('click', () => copyText(el('mk-link')).then(ok => fail(ok ? '已复制。链接就是钥匙,请只发给要聊天的人。' : '复制失败:链接已选中,请手动复制。')));
el('cv-make').addEventListener('click', async () => {
const say = t => { el('cv-error').textContent = t; }; el('cv-download').hidden = true; el('cv-make').disabled = true; say('');
try {
diff --git a/html-room/style.css b/html-room/style.css
index a633ab1..1639aa6 100644
--- a/html-room/style.css
+++ b/html-room/style.css
@@ -1 +1 @@
-:root{color-scheme:dark;font-family:system-ui,sans-serif;background:#121820;color:#edf2f7}body{max-width:760px;margin:auto;padding:20px}h1{font-size:1.5rem}p{line-height:1.6}aside{border:1px solid #dcac58;padding:12px;border-radius:10px;color:#ffe0a3}input,textarea,button{font:inherit;padding:12px;border:1px solid #65778c;border-radius:8px;background:#202d3b;color:inherit;box-sizing:border-box}input,textarea{width:100%;margin:8px 0}button{cursor:pointer}button:disabled{opacity:.5}ol{padding:0;list-style:none}li{padding:12px;margin:8px 0;background:#202d3b;border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere}small{display:block;color:#b9c8d8;margin-top:6px}[hidden]{display:none}#status{color:#a7d9ff}textarea{min-height:90px}.hint{color:#b9c8d8;font-size:.92rem}code{background:#202d3b;padding:1px 4px;border-radius:4px;overflow-wrap:anywhere}footer{margin-top:32px;border-top:1px solid #34465a}a{color:#a7d9ff}details{margin:14px 0}summary{cursor:pointer}#mk-make,#enter,#send{background:#2f6fb0;border-color:#4a8bd0;font-weight:600;padding:12px 22px}button{margin:4px 0}.check{display:flex;gap:8px;align-items:center;margin:10px 0}.check input{width:auto;margin:0}
\ No newline at end of file
+:root{color-scheme:dark;font-family:system-ui,sans-serif;background:#121820;color:#edf2f7}body{max-width:760px;margin:auto;padding:20px}h1{font-size:1.5rem}p{line-height:1.6}aside{border:1px solid #dcac58;padding:12px;border-radius:10px;color:#ffe0a3}input,textarea,button{font:inherit;padding:12px;border:1px solid #65778c;border-radius:8px;background:#202d3b;color:inherit;box-sizing:border-box}input,textarea{width:100%;margin:8px 0}button{cursor:pointer}button:disabled{opacity:.5}ol{padding:0;list-style:none}li{padding:12px;margin:8px 0;background:#202d3b;border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere}small{display:block;color:#b9c8d8;margin-top:6px}[hidden]{display:none}#status{color:#a7d9ff}textarea{min-height:90px}.hint{color:#b9c8d8;font-size:.92rem}code{background:#202d3b;padding:1px 4px;border-radius:4px;overflow-wrap:anywhere}footer{margin-top:32px;border-top:1px solid #34465a}a{color:#a7d9ff}details{margin:14px 0}summary{cursor:pointer}#mk-make,#enter,#send{background:#2f6fb0;border-color:#4a8bd0;font-weight:600;padding:12px 22px}button{margin:4px 0}.pw,#mk-pass,#mk-pass-out,#password{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.02em}.row{display:flex;gap:8px;align-items:center}.row input{flex:1;min-width:0}.row button{flex:none}.check{display:flex;gap:8px;align-items:center;margin:10px 0}.check input{width:auto;margin:0}
\ No newline at end of file
diff --git a/html-room/template.js b/html-room/template.js
index 80b23d1..82f8984 100644
--- a/html-room/template.js
+++ b/html-room/template.js
@@ -17,7 +17,7 @@ const RETARGET = `生成新文件:换
// The creator: open in a blank file (the offline generator and the hosted
// site), folded away in a room file. Everything happens in this page, offline.
-const MAKER = `创建一个新房间(新的钥匙,可设新口令)
`;
+const MAKER = `创建一个新房间(新的钥匙,可设新口令)
`;
// The file is also a Node script. This comment, before , holds
// the relay (html-room/serve.js) for `node index.html`; browsers skip it. To
@@ -29,7 +29,7 @@ export const SERVER_FORBIDDEN = /\*\/|-->||<\/?(?:script|style)/i;
export function roomHtml(config, script, style, scriptHash, styleHash, comment = '') {
if (comment && SERVER_FORBIDDEN.test(comment.replace(/\/\*\n$/, ''))) throw Error('服务端代码含不安全序列');
const csp = cspFor(config, scriptHash, styleHash), title = esc(config.title ?? DEFAULT_TITLE);
- const html=`${comment ? `\n` : ''}${title}${title}
正在启动……如果一直停在这里,请看下面的说明。
${previewHelp(config)}${RETARGET}
${MAKER}\n${comment ? '\n' : ''}`;
+ const html=`${comment ? `\n` : ''}${title}${title}
正在启动……如果一直停在这里,请看下面的说明。
${previewHelp(config)}${RETARGET}
${MAKER}\n${comment ? '\n' : ''}`;
return { html, csp };
}
diff --git a/html-room/ui.js b/html-room/ui.js
new file mode 100644
index 0000000..6e62d92
--- /dev/null
+++ b/html-room/ui.js
@@ -0,0 +1,21 @@
+// Small helpers shared by the room and the creator.
+
+// Copies an input's text: the Clipboard API where allowed, else the old
+// select-and-copy (some file:// and in-app browsers). Leaves the text selected
+// either way, so it can still be copied by hand. Resolves to whether it worked.
+export async function copyText(input) {
+ input.focus(); input.select(); input.setSelectionRange?.(0, input.value.length);
+ try { await navigator.clipboard.writeText(input.value); return true; } catch {}
+ try { return document.execCommand('copy'); } catch { return false; }
+}
+
+// A show/hide button for a password field.
+export function showToggle(input, button) {
+ const show = on => {
+ input.type = on ? 'text' : 'password';
+ button.textContent = on ? '隐藏' : '显示';
+ button.setAttribute('aria-pressed', String(on));
+ };
+ button.addEventListener('click', () => show(input.type === 'password'));
+ return { show };
+}
diff --git a/package.json b/package.json
index dbb2e6a..f8b13aa 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"build:pages": "node scripts/build-html-room.mjs pages",
"build:check": "node scripts/build-html-room.mjs template --check",
"test": "node --test --test-timeout=300000",
- "test:browser": "node browser-tests/htmlroom.mjs && node browser-tests/html-room-preview.mjs && node browser-tests/multi-relay.mjs && node browser-tests/retarget.mjs && node browser-tests/mesh.mjs && node browser-tests/custom-relay.mjs && node browser-tests/generations.mjs && node browser-tests/file-server.mjs",
+ "test:browser": "node browser-tests/htmlroom.mjs && node browser-tests/html-room-preview.mjs && node browser-tests/multi-relay.mjs && node browser-tests/retarget.mjs && node browser-tests/mesh.mjs && node browser-tests/custom-relay.mjs && node browser-tests/generations.mjs && node browser-tests/file-server.mjs && node browser-tests/password-ui.mjs",
"test:retarget": "node browser-tests/retarget.mjs",
"test:failover": "node browser-tests/multi-relay.mjs",
"test:pages": "npm run build:pages && node browser-tests/pages.mjs",