diff --git a/README.md b/README.md
index 9bbf074..f1d7c4a 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,7 @@ flowchart TD
**第 1 步:创建房间**
打开 [房间生成器](https://freecode100year.github.io/htmlroom/),直接点「创建房间」就行,其他设置都不用改。
如果想多一层保险,可以点「随机生成口令」设个密码(相当于门上再加一把锁)。
+还可以给房间起个名字(「房间标题」,最多 60 字),它会显示在浏览器标签页和页面顶部;不填就是「聚众加密聊天房间」。持有房间文件的人以后也能在「生成新文件」里改名,房间和口令不变。
**第 2 步:发给朋友**
点「创建房间」后,你可以:
diff --git a/browser-tests/generations.mjs b/browser-tests/generations.mjs
index 9cbf974..2d428ca 100644
--- a/browser-tests/generations.mjs
+++ b/browser-tests/generations.mjs
@@ -23,8 +23,9 @@ try {
const offline = await browser.newContext({ acceptDownloads: true }); await offline.setOffline(true);
const errors = [], requests = [];
const watch = p => { p.on('pageerror', e => errors.push(e.message)); p.on('request', r => { if (!/^(file|blob|data):/.test(r.url())) requests.push(r.url()); }); return p; };
- const create = async (page, { relays, password, expand }) => {
+ const create = async (page, { relays, password, expand, title }) => {
if (expand) await page.locator('#new-room > summary').click();
+ if (title) await page.locator('#mk-title').fill(title);
if (relays) { if (!(await page.locator('#mk-relay').isVisible())) await page.locator('#mk-advanced > summary').click(); await page.locator('#mk-relay').fill(relays); }
if (password) await page.locator('#mk-pass').fill(password);
await page.locator('#mk-make').click();
@@ -42,11 +43,24 @@ try {
assert.equal(await g0.locator('#maker').isVisible(), true);
assert.equal(await g0.locator('#unlock').isVisible(), false);
assert.equal(await g0.locator('#mk-relay').inputValue(), '');
- const a = await create(g0, { relays: relay.url, password: 'first pass' });
- assert.equal(a.config.custom, true);
+ assert.equal(await g0.title(), '聚众加密聊天房间');
+ const a = await create(g0, { relays: relay.url, password: 'first pass', title: '周末读书会 ' });
+ assert.equal(a.config.custom, true); assert.equal(a.config.title, '周末读书会 ');
// Generation 1: room A creates room B with a different password.
const g1 = watch(await offline.newPage()); await g1.goto(pathToFileURL(a.path).href);
+ assert.equal(await g1.title(), '周末读书会 ', 'the title shows as text, not markup');
+ assert.equal(await g1.locator('h1').first().textContent(), '周末读书会 ');
+ // Any holder can rename the room by regenerating the file (same key, same password).
+ await g1.locator('#retarget > summary').click();
+ assert.equal(await g1.locator('#rt-title').inputValue(), '周末读书会 ');
+ await g1.locator('#rt-title').fill('读书会第二季'); await g1.locator('#rt-make').click();
+ const rdl = g1.waitForEvent('download'); await g1.locator('#rt-download').click();
+ const renamedPath = dir + '/renamed.html'; await (await rdl).saveAs(renamedPath);
+ const renamed = await readFile(renamedPath, 'utf8'); await checkHtml(renamed, blank.scriptHash);
+ const rc = configFromRoomHtml(renamed);
+ assert.equal(rc.title, '读书会第二季'); assert.equal(rc.roomId, a.config.roomId); assert.equal(rc.ciphertext, a.config.ciphertext);
+ await g1.locator('#retarget > summary').click();
assert.equal(await g1.locator('#unlock').isVisible(), true);
assert.equal(await g1.locator('#maker').isVisible(), false, 'folded in a room file');
await g1.locator('#new-room > summary').click();
@@ -54,6 +68,7 @@ try {
assert.equal(await g1.locator('#mk-relay').inputValue(), relay.url, 'starts from this room\'s relays');
const b = await create(g1, { password: 'second pass' });
assert.notEqual(b.config.roomId, a.config.roomId, 'a new room, not a copy');
+ assert.equal(b.config.title, undefined, 'a new room starts with the default title unless one is given');
assert.equal((await unlock(b.config, 'second pass')).roomId, b.config.roomId);
await assert.rejects(unlock(b.config, 'first pass'));
@@ -83,7 +98,7 @@ try {
const again = watch(await offline.newPage()); await again.goto(pathToFileURL(selfPath).href);
assert.equal(await again.locator('#mk-relay').inputValue(), relay.url);
assert.deepEqual(errors, []);
- console.log('PASS: blank creator -> room A -> room B (new key, new password) -> room C, all offline and byte-identical to release builds; chat in B; site saves itself as an offline creator');
+ console.log('PASS: titled room A, renamed by a holder; blank creator -> room A -> room B (new key, new password) -> room C, all offline and byte-identical to release builds; chat in B; site saves itself as an offline creator');
} finally {
await browser?.close(); await relay.stop(); await rm(dir, { recursive: true, force: true });
}
diff --git a/dist/_headers b/dist/_headers
index 8b6c6ab..91c1b09 100644
--- a/dist/_headers
+++ b/dist/_headers
@@ -1,5 +1,5 @@
/*
- Content-Security-Policy: default-src 'none'; script-src 'sha256-t+UQwWWn39gEDqed1s6sSnhEiZjNGlRobivE0cpid5M='; 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-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'
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 30a645a..621aaca 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。
下载新的房间文件
创建一个新房间(新的钥匙,可设新口令)
+`:""}`,csp:i}}function Gr(e,t,r=e.custom===!0,n=e.title){tt(e);let s=ee(n),{custom:o,title:i,...c}=e;e={...c,...r&&{custom:!0},...s&&{title:s}};let a=Tt(t);return tt({...e,version:re(a),relays:a})}var Vo=async e=>btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.digest("SHA-256",new TextEncoder().encode(e)))));async function Go(e,t,r,n,s){let o=Gr(e,t,n,s);return{config:o,html:await we(o,r)}}function zo(e){if(typeof e!="string"||e.length>4e5)throw Error("\u4E0D\u662F\u6709\u6548\u7684\u623F\u95F4\u6587\u4EF6");let t=e.match(/\x3Cscript id="pt-room-config" type="application\/json">([\s\S]*?)\x3C\/script>/);if(!t)throw Error("\u4E0D\u662F\u6709\u6548\u7684\u623F\u95F4\u6587\u4EF6");return tt(JSON.parse(t[1]))}async function we(e,{script:t,style:r,comment:n=""}){if(typeof t!="string"||typeof r!="string"||/\x3C\/script/i.test(t)||/\x3C\/style/i.test(r))throw Error("\u811A\u672C\u6216\u6837\u5F0F\u4E0D\u53EF\u7528");return e.version?tt(e):ve(e),Wi(e,t,r,await Vo(t),await Vo(r),n).html}var Zo=document.currentScript?.textContent,Xo=document.querySelector("style")?.textContent,Ji=document.firstChild?.nodeType===Node.COMMENT_NODE?document.firstChild.data:"",nr={script:Zo,style:Xo,comment:Ji},A=e=>document.getElementById(e),Xr=/^https?:$/.test(location.protocol),Fr=e=>/[一-鿿]/.test(e?.message??""),or=new Map;function sr(e,t){or.has(e)&&URL.revokeObjectURL(or.get(e));let r=URL.createObjectURL(new Blob([t],{type:"text/html"}));or.set(e,r),A(e).href=r,A(e).hidden=!1}addEventListener("pagehide",()=>{for(let e of or.values())URL.revokeObjectURL(e)});var ut;try{ut=JSON.parse(A("pt-room-config").textContent)}catch{ut=null}function Qi(e){let t=r=>{A("rt-status").textContent=r};A("retarget").hidden=!1,A("rt-relay").value=e.relays.join(" "),A("rt-title").value=e.title??"",A("rt-make").addEventListener("click",async()=>{A("rt-download").hidden=!0,A("rt-make").disabled=!0,t("");try{let r=await Go(e,A("rt-relay").value,nr,void 0,A("rt-title").value);if(JSON.stringify(r.config.relays)===JSON.stringify(e.relays)&&r.config.title===e.title){t("\u4E2D\u7EE7\u548C\u6807\u9898\u90FD\u548C\u73B0\u5728\u4E00\u6837\uFF0C\u4E0D\u7528\u751F\u6210\u3002");return}sr("rt-download",r.html),t(`\u65B0\u6587\u4EF6\u300C${r.config.title??It}\u300D\u5DF2\u751F\u6210\uFF08\u4E2D\u7EE7\uFF1A${r.config.relays.join("\u3001")}\uFF09\u3002\u53E3\u4EE4\u4E0D\u53D8\u3002\u8BF7\u628A\u5B83\u53D1\u7ED9\u6240\u6709\u4EBA\uFF0C\u5927\u5BB6\u90FD\u7528\u65B0\u6587\u4EF6\u6253\u5F00\uFF0C\u518D\u6838\u5BF9\u4E00\u6B21\u5B89\u5168\u7801\u3002`)}catch(r){t(Fr(r)?`\u751F\u6210\u5931\u8D25\uFF1A${r.message}\u3002`:"\u751F\u6210\u5931\u8D25\uFF1A\u5730\u5740\u683C\u5F0F\u4E0D\u5BF9\u3002\u4F8B\u5B50\uFF1Awss://relay.example.com")}finally{A("rt-make").disabled=!1}})}function zr(e,{open:t,siteTitle:r}){let n=s=>{A("mk-error").textContent=s};A("new-room").hidden=!1,t&&(A("new-room").open=!0,A("new-room").querySelector("summary").hidden=!0),A("mk-relay").value=e.join(" "),e.length||(A("mk-advanced").open=!0),A("mk-gen").addEventListener("click",()=>{A("mk-pass").value=G(Q(32))}),A("mk-mesh").addEventListener("click",()=>{let s=A("mk-relay").value.trim().split(/[\s,]+/).filter(Boolean).map(o=>/^wss?:\/\//.test(o)?`bc+${o.replace(/\/+$/,"")}/bc/`:o);A("mk-relay").value=s.join(" "),n(s.length?"\u5DF2\u6362\u6210\u53BB\u4E2D\u5FC3\u5316\u4E2D\u7EE7\u3002\u8FD8\u53EF\u4EE5\u5728\u540E\u9762\u52A0\u4E0A nostr+wss://\u2026 \u6216 mqtt+wss://\u2026 \u5F53\u5907\u4EFD\u3002":"\u5148\u586B\u4E00\u4E2A\u4E2D\u7EE7\u5730\u5740\uFF0C\u4F8B\u5982 bc+wss://relay.example.com")}),A("maker").addEventListener("submit",async s=>{s.preventDefault(),A("mk-make").disabled=!0,n(""),A("mk-out").hidden=!0;try{let o=Tt(A("mk-relay").value),i=A("mk-pass").value;if(i&&new TextEncoder().encode(i).length>1024)throw Error("\u53E3\u4EE4\u592A\u957F\u4E86");let c=await sn(o,i||void 0,{custom:A("mk-custom").checked,title:A("mk-title").value});sr("mk-download",await we(c,nr)),A("mk-out").hidden=!1,A("mk-pass").value="",Xr?(A("mk-link").value=location.origin+location.pathname+Vr(c),A("mk-link-row").hidden=!1):(A("mk-link").value="",A("mk-link-row").hidden=!0),n(i?"\u2705 \u623F\u95F4\u5EFA\u597D\u4E86\u3002\u53E3\u4EE4\u8BF7\u6362\u4E00\u79CD\u65B9\u5F0F\u544A\u8BC9\u670B\u53CB\uFF08\u6BD4\u5982\u6253\u7535\u8BDD\uFF09\uFF0C\u522B\u548C\u6587\u4EF6\u4E00\u8D77\u53D1\u3002":"\u2705 \u623F\u95F4\u5EFA\u597D\u4E86\u3002\u6587\u4EF6\u5C31\u662F\u94A5\u5319\uFF0C\u8BF7\u53EA\u53D1\u7ED9\u8981\u804A\u5929\u7684\u4EBA\u3002")}catch(o){n(Fr(o)?`\u521B\u5EFA\u5931\u8D25\uFF1A${o.message}`:"\u521B\u5EFA\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u4E2D\u7EE7\u5730\u5740\u540E\u518D\u8BD5\u3002"),A("mk-advanced").open=!0}finally{A("mk-make").disabled=!1}}),A("mk-copy").addEventListener("click",()=>navigator.clipboard?.writeText(A("mk-link").value).then(()=>n("\u5DF2\u590D\u5236\u3002\u94FE\u63A5\u5C31\u662F\u94A5\u5319\uFF0C\u8BF7\u53EA\u53D1\u7ED9\u8981\u804A\u5929\u7684\u4EBA\u3002"),()=>n("\u590D\u5236\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u9009\u4E2D\u94FE\u63A5\u590D\u5236\u3002"))),A("cv-make").addEventListener("click",async()=>{let s=o=>{A("cv-error").textContent=o};A("cv-download").hidden=!0,A("cv-make").disabled=!0,s("");try{let o=A("cv-file").files?.[0];if(!o)throw Error("\u8BF7\u5148\u9009\u62E9\u623F\u95F4\u6587\u4EF6");if(o.size>4e5)throw Error("\u8FD9\u4E2A\u6587\u4EF6\u592A\u5927\u4E86\uFF0C\u4E0D\u662F\u623F\u95F4\u6587\u4EF6");let i;try{i=zo(await o.text())}catch{throw Error("\u8BFB\u4E0D\u51FA\u623F\u95F4\u4FE1\u606F\u3002\u5982\u679C\u662F\u5F88\u65E9\u7684\u65E7\u7248\u6587\u4EF6\uFF0C\u8BF7\u91CD\u65B0\u521B\u5EFA\u623F\u95F4")}let c=Gr(i,A("cv-relay").value,A("cv-custom").checked);sr("cv-download",await we(c,nr)),s(`\u65B0\u6587\u4EF6\u5DF2\u751F\u6210\uFF08\u4E2D\u7EE7\uFF1A${c.relays.join("\u3001")}\uFF09\u3002${c.encrypted?"\u53E3\u4EE4\u4E0D\u53D8\u3002":""}\u8BF7\u628A\u5B83\u53D1\u7ED9\u6240\u6709\u4EBA\uFF0C\u5927\u5BB6\u90FD\u7528\u65B0\u6587\u4EF6\u6253\u5F00\uFF0C\u518D\u6838\u5BF9\u4E00\u6B21\u5B89\u5168\u7801\u3002`)}catch(o){s(Fr(o)?o.message:"\u751F\u6210\u5931\u8D25\uFF1A\u8BF7\u68C0\u67E5\u4E2D\u7EE7\u5730\u5740\u3002")}finally{A("cv-make").disabled=!1}}),Xr&&we({...e.length&&{relays:e},...r&&{title:r}},nr).then(s=>{sr("mk-self",s),A("mk-self-row").hidden=!1}).catch(()=>{})}function tc(){for(let e of["risk","status","preview-help","unlock","retarget","relay-status","presence","safety","messages","chat"])A(e).hidden=!0;document.querySelector("h1").hidden=!0}function ec(e){history.replaceState(null,"",location.pathname+location.search);let t;try{t=Po(e)}catch{return A("status").hidden=!1,A("status").textContent="\u623F\u95F4\u94FE\u63A5\u6253\u4E0D\u5F00\uFF1A\u94FE\u63A5\u4E0D\u5B8C\u6574\u6216\u5DF2\u635F\u574F\u3002\u8BF7\u8BA9\u5BF9\u65B9\u91CD\u53D1\u94FE\u63A5\uFF0C\u6216\u76F4\u63A5\u7528\u623F\u95F4\u6587\u4EF6\u3002",A("enter").disabled=!0,!1}return Pr(t),!0}var Zr=Zo&&Xo;try{if(ut?.version)tt(ut),Pr(ut),Zr&&(Qi(ut),zr(ut.relays,{open:!1}));else if(ut){ve(ut);let e=ut.relays??(Xr?[location.origin.replace(/^http/,"ws")]:[]);location.hash.startsWith("#hr1=")?(A("new-room").hidden=!0,ec(location.hash)&&Zr&&zr(e,{open:!1})):(tc(),Zr&&zr(e,{open:!0,siteTitle:ut.title}),addEventListener("hashchange",()=>{location.hash.startsWith("#hr1=")&&location.reload()}))}else throw Error("bad config")}catch{A("status").hidden=!1,A("status").textContent="\u623F\u95F4\u6587\u4EF6\u6709\u95EE\u9898\uFF0C\u8BF7\u8BA9\u5BF9\u65B9\u91CD\u65B0\u53D1\u4E00\u4EFD\u3002",A("enter").disabled=!0}})();