dl.f3liz.casa/drop を読んでいます
std-actor
drop の殻: logic の三つの door(setup / start / dispatch)を呼んで、view を描き、effect を carry out する。actor.ts を書かない drop の中身
uuid 41617246-60d5-4808-bbfd-7dec87e21d26
install
Press inside noraneko. It catches the link, opens its settings with this uuid, downloads the xpi, checks the sha256 and the registry's signature, and shows you everything below again before anything runs.
about:nora:settings in noraneko instead.
who
gh/nyanruswhat is served
lib · std-actor@noraneko.app · version 1.1.0 · 67203 bytes sha256 0f93ce141ab2a288cfdd27edd651eee376974aabdc846fadf01379cb5dce06ac built from drops/std-actor/src @ 59b96c854c (2026-09-14T07:24:00Z) served at https://dl.f3liz.casa/drop/41617246-60d5-4808-bbfd-7dec87e21d26/in the registry
drops/std-actor/ @ 59b96c854c — この版が出てきた木 library: 直接入れるものではなく、これを[deps] に書いた drop に付いてくる
the source, as it is
These come from source.json next to the manifest — the same text the xpi carries under source/, and dl checks its sha256 against the signed manifest before handing it over. Not a copy of the repository: what you read and what runs came out of the same build. 9 files; press one to open it.
_shared/contentRuntime.ts · 50 lines
// SPDX-License-Identifier: MPL-2.0
// Shared page-side runtime. Bundled into every actor's content.js.
//
// content.js is loaded by the generated child.sys.mjs (a JSWindowActorChild) with
// Services.scriptloader.loadSubScript(url, scope). `scope` puts `window`,
// `document`, `exportFunction` and `__nora` on the scope chain, so the actor's
// content hook can use `window` / `document` as free variables just like a
// content script would, while actually running with the child actor's
// privileges in the page's process. `__nora` is the bridge to the parent
// (sendQuery) and to the page (exportFunction).
import type { ActorMeta, ContentCtx, ContentHook, Ops } from "./defineActor.ts";
import { makeIo } from "./io.ts";
// Provided on the scope chain by child.sys.mjs.
declare const __nora: {
call(method: string, args: unknown[]): Promise<unknown>;
expose(funcs: Record<string, (...args: any[]) => unknown>): void;
onDestroy(fn: () => void): void;
base: string;
tsubaki: Ops | undefined;
};
export function runContent(_meta: ActorMeta, hook: ContentHook): void {
const parent = new Proxy(
{},
{
get(_target, method: string) {
return (...args: unknown[]) => __nora.call(method, args);
},
},
) as any;
const ctx: ContentCtx = {
dev: import.meta.env.MODE === "dev",
expose(funcs) {
__nora.expose(funcs);
},
onDestroy(fn) {
__nora.onDestroy(fn);
},
io: makeIo((fn) => __nora.onDestroy(fn)),
base: __nora.base,
ops: __nora.tsubaki,
};
hook(parent, ctx);
}
_shared/defineActor.ts · 118 lines
// SPDX-License-Identifier: MPL-2.0
// Authoring surface for an "actor": a small xpi whose page side is a
// JSWindowActor (the same shape as Firefox's own about:newtab add-on). Each
// actor lives in one file that exports three things by name:
//
// export const meta = { id, namespace, matches, ... };
// export const parent = defineParent({ method(...) { ... } }); // main process (JSWindowActorParent)
// export const content = defineContent((parent, ctx) => { ... }); // page side (run by JSWindowActorChild)
//
// The page side is NOT a WebExtension content script: content scripts are not
// injected into about:* / chrome:* documents in stock Firefox (see README,
// "addon 式が駄目だった理由"). The child actor loads content.js into the page's
// process with `window` / `document` on the scope chain, so the hook is written
// the same way, but it runs with the child actor's privileges.
//
// Named exports (rather than one nested object) are deliberate: the build
// bundles `parent` and `content` into separate outputs, and named exports let
// the bundler tree-shake each side so the content bundle never drags in the
// parent's privileged code and vice versa.
//
// IMPORTANT: the module top level must be pure. Reference Firefox globals
// (Services, ChromeUtils, window, exportFunction, browser) only *inside* method
// or hook bodies — the build imports this file under Deno to read `meta`, and
// top-level use of those globals would throw there.
export interface ActorMeta {
/** Extension id, e.g. "settings-bridge@noraneko.app". */
id: string;
/** Extension version. */
version: string;
/** Experiment-API namespace, e.g. "noraSettings". Also the message channel. */
namespace: string;
/** Match patterns for the pages the content hook runs in (JSWindowActor `matches`). */
matches: string[];
/**
* When the content hook runs. Maps to a child actor event:
* document_start → DOMDocElementInserted, document_end → DOMContentLoaded,
* document_idle → load. Defaults to "document_end".
*/
runAt?: "document_start" | "document_end" | "document_idle";
/**
* JSWindowActor name. Defaults to "Nora" + PascalCase(<dir>), e.g. NoraNewtab.
* A drop with the same actor name replaces the built-in one (unregister → register).
*/
actor?: string;
/** Legacy JSActor (BrowserGlue) this one replaces; it is unregistered when this actor is registered. */
replaces?: string;
}
import type { Io } from "./io.ts";
type ParentMethods = Record<string, (...args: any[]) => unknown>;
export interface ContentCtx {
/** True in dev builds (import.meta.env.MODE === "dev"). */
dev: boolean;
/** Expose functions on the page window (via exportFunction). */
expose(funcs: Record<string, (...args: any[]) => unknown>): void;
/**
* Run when this actor goes away in this window (the drop is removed or
* replaced). Put back what the hook changed: DOM, observers, listeners.
*/
onDestroy(fn: () => void): void;
/**
* Place things through these (DOM, styles, listeners, pref observers) and
* they are taken out again by themselves: each verb puts its way back on
* the same ledger as onDestroy.
*/
io: Io;
/** This actor's own files: `${ctx.base}ops/x.tsubaki` and the like. */
base: string;
/**
* The actor's logic in Tsubaki (when it ships wasm/): run a .tsubaki file of
* its own with load("ops/x.tsubaki"), then call(name, ...args). Values cross
* as ordinary JS values; a Tsubaki error is a JS Error. undefined without wasm/.
*
* Every verb answers with a Promise, because for an actor of the browser
* WINDOW the logic lives a process away (wasm can't compile in the parent
* process, so it runs in a hidden page of this window instead). A drop never
* has to know which side it got.
*/
ops: Ops | undefined;
}
export interface Ops {
ready: Promise<void>;
call(name: string, ...args: unknown[]): Promise<unknown>;
}
/** Proxy to the parent methods; each call is forwarded to the main process. */
type ParentProxy<T extends ParentMethods> = {
[K in keyof T]: (...args: Parameters<T[K]>) => Promise<Awaited<ReturnType<T[K]>>>;
};
export type ContentHook<T extends ParentMethods = ParentMethods> = (
parent: ParentProxy<T>,
ctx: ContentCtx,
) => void;
// Identity helpers that pin types for authoring:
//
// export const parent = defineParent({ method(...) { ... } });
// export const content = defineContent<typeof parent>((parent, ctx) => { ... });
//
// They are listed in the build's treeshake.manualPureFunctions, so an unused
// call (e.g. defineContent in the parent bundle) is dropped along with anything
// it references (birpc). Without that, the bundler would keep the call for its
// possible side effects and the unused side would not tree-shake.
/** Pins the parent-method types so the content hook's proxy is typed. */
export const defineParent = <T extends ParentMethods>(methods: T): T => methods;
/** Pins the content hook against the parent's method types. */
export const defineContent = <T extends ParentMethods>(
hook: ContentHook<T>,
): ContentHook<T> => hook;
_shared/io.ts · 53 lines
// SPDX-License-Identifier: MPL-2.0
// The io layer of a content hook: every verb here *places* something in the
// window and, in the same breath, puts the way back on the ledger
// (ctx.onDestroy). When the drop is removed or replaced, the ledger is run in
// reverse and the window is as it was. A hook that only ever places through
// these verbs has no cleanup to write.
export interface Io {
/** Insert a node and remember to remove it. */
place(node: Node, at: { parent: Node } | { before: Node } | { after: Node }): void;
/** Add a <style> to the document head and remember to remove it. */
style(doc: Document, css: string): HTMLStyleElement;
/** addEventListener, and remember the matching removeEventListener. */
listen<K extends string>(
target: EventTarget,
type: K,
fn: (ev: any) => void,
options?: boolean | AddEventListenerOptions,
): void;
/** Watch a pref and remember to stop watching. */
pref(name: string, fn: () => void): void;
/** Anything else that has to be put back: hand the way back to the ledger. */
defer(fn: () => void): void;
}
export function makeIo(onDestroy: (fn: () => void) => void): Io {
return {
place(node, at) {
if ("parent" in at) at.parent.appendChild(node);
else if ("before" in at) (at.before as ChildNode).before(node);
else (at.after as ChildNode).after(node);
onDestroy(() => (node as ChildNode).remove());
},
style(doc, css) {
const el = doc.createElementNS("http://www.w3.org/1999/xhtml", "style") as HTMLStyleElement;
el.textContent = css;
this.place(el, { parent: doc.head ?? doc.documentElement });
return el;
},
listen(target, type, fn, options) {
target.addEventListener(type, fn, options);
onDestroy(() => target.removeEventListener(type, fn, options));
},
pref(name, fn) {
const observer = () => fn();
Services.prefs.addObserver(name, observer);
onDestroy(() => Services.prefs.removeObserver(name, observer));
},
defer: onDestroy,
};
}
abi.json · 289 lines
{
"abi": "1.0",
"note": "殻が drop に許していることの、ただ一つの表。ここから四つが読む: 殻(実行時に断る)、build(手元で断る・印を押す)、店の画面(入れる人に出す日本語)、docs。手で二度書かない。",
"permissions": {
"prefs": {
"shape": "names",
"ja": "あなたの設定のうち、ここに挙げたものを読み書きします: {names}",
"note": "自分の名前空間(noraneko.<drop の名前>.*)は、書かなくても読み書きできる。それ以外は名指しだけ。"
},
"current_url": {
"shape": "flag",
"ja": "いま見ているタブの URL を知ります"
},
"open_url": {
"shape": "flag",
"ja": "web のページを、新しいタブで開きます"
},
"web_frame": {
"shape": "flag",
"ja": "ページを読み込む窓を、この drop が置いた場所に置きます"
},
"chrome_style": {
"shape": "flag",
"ja": "自分が置いたもの以外の、ブラウザの見た目も変えます"
},
"keys": {
"shape": "names",
"ja": "キーボードの {names} を、この drop が受け取ります",
"note": "宣言した組み合わせだけ。view が他の <key> を書いたら、その一つだけ置かない。"
},
"commands": {
"shape": "names",
"ja": "ブラウザの、この命令を実行します: {names}",
"names_from": "commands",
"note": "表(下の commands)に有って、かつここに挙げた名前だけ。どちらか片方でも欠けたら実行しない。"
},
"browser_pages": {
"shape": "names",
"ja": "ブラウザ自身のこのページを、置いた窓に読み込みます: {names}",
"names_from": "browser_pages",
"note": "名指しだけ。任意の chrome: / about: は開かない。web_frame も要る(窓を置くので)。"
},
"tabs": {
"shape": "level",
"levels": {
"read": "開いているタブのこと(題・固定・音・選ばれているか)を読んで、開いた/閉じた/選ばれたを知ります",
"write": "開いているタブのことを読んで、タブを選ぶ・仕舞う(タブ帯から隠す)・また見せることができます"
},
"ja": "開いているタブのことを読みます",
"note": "段は並んでいる順に強い(read の次が write)。増やすのは一つずつ -- その effect を使う drop が出るときに、その effect と一緒に。先に表にだけ載せると、入れる人の画面に裏づけの無い行が出る。\"write\" にも、閉じるのと動かすのは入っていない(閉じるのは命令の表のほう)。URL は current_url も書いた drop にだけ渡す(Ask(\"url\") と同じ線)。"
},
"tab_marks": {
"shape": "flag",
"ja": "タブに、この drop の目印を付けます(その目印で、タブの見た目を変えられます)",
"note": "目印の名前は殻が決める(data-nora-<uuid>-<名前>)ので、他の drop や Firefox 自身の覚書を踏むことが起きない。"
},
"tab_values": {
"shape": "flag",
"ja": "タブに、この drop の覚書を残します(閉じて開き直しても、そのタブに付いてきます)",
"note": "SessionStore の per-tab value。鍵は殻が nora.<uuid>.<鍵> にする。読み戻す鍵は setup() の tab_values に並べる。"
},
"window_values": {
"shape": "flag",
"ja": "この窓に、この drop の覚書を残します(窓を閉じて戻しても、その窓に付いてきます)",
"note": "SessionStore の per-window value。鍵は殻が nora.<uuid>.<鍵> にする。読み戻す鍵は setup() の window_values に並べる。タブの覚書と分けてあるのは置き場所が別だから -- 窓のほうは「その窓で何を開いていたか」のような、タブ一枚に属さないことのため。"
},
"prompt": {
"shape": "flag",
"ja": "字を打つ小さな欄を出して、そのあいだキーボードの焦点を受け取ります"
},
"menu": {
"shape": "names",
"ja": "ブラウザのこの menu に、行を混ぜます: {names}",
"names_from": "menus",
"note": "名指しだけ。混ぜるのは末尾で、本体の行は動かさない。"
}
},
"effects": {
"SetPref": { "args": ["name", "value"], "permission": "prefs", "ja": "設定を書く" },
"OpenURL": { "args": ["url"], "permission": "open_url", "ja": "web の URL をタブで開く(http/https だけ)" },
"Ask": { "args": ["fields", "action"], "permission": null, "ja": "事実を訊く(要る permission は facts の側)" },
"Measure": { "args": ["selector", "action"], "permission": null, "ja": "自分が置いたものの大きさを測る" },
"OpenPopup": { "args": ["selector", "x", "y"], "permission": null, "ja": "自分が置いた menupopup を開く" },
"ReloadFrame": { "args": ["selector"], "permission": "web_frame", "ja": "自分が置いた窓を読み込み直す" },
"DoCommand": { "args": ["name"], "permission": "commands", "ja": "ブラウザの命令を一つ実行する" },
"Log": { "args": ["text"], "permission": null, "ja": "console に一行" },
"SetTabAttr": { "args": ["tab", "name", "value"], "permission": "tab_marks", "ja": "タブに、この drop の目印を一つ付ける" },
"ClearTabAttr": { "args": ["tab", "name"], "permission": "tab_marks", "ja": "その目印を外す" },
"SetTabValue": { "args": ["tab", "key", "value"], "permission": "tab_values", "ja": "タブに、この drop の覚書を一つ残す" },
"ClearTabValue": { "args": ["tab", "key"], "permission": "tab_values", "ja": "その覚書を消す" },
"Prompt": { "args": ["tab", "action", "value", "placeholder"], "permission": "prompt", "ja": "そのタブの名札のところに、字を打つ欄を出す" },
"HideTab": { "args": ["tab"], "permission": "tabs", "level": "write", "ja": "そのタブを、タブ帯から仕舞う" },
"ShowTab": { "args": ["tab"], "permission": "tabs", "level": "write", "ja": "仕舞ったタブを、また見せる" },
"SelectTab": { "args": ["tab"], "permission": "tabs", "level": "write", "ja": "そのタブを選ぶ" },
"SetWindowValue": { "args": ["key", "value"], "permission": "window_values", "ja": "この窓に、この drop の覚書を一つ残す" },
"ClearWindowValue": { "args": ["key"], "permission": "window_values", "ja": "その覚書を消す" }
},
"facts": {
"uuid": { "permission": null, "ja": "新しい uuid を一つ" },
"url": { "permission": "current_url", "ja": "いま見ているタブの URL" },
"tabs": { "permission": "tabs", "ja": "この窓の、開いているタブの一覧" },
"tab": { "permission": "tabs", "ja": "いま選ばれているタブ" }
},
"tab": {
"note": "一枚のタブが、logic にどう見えるか。`id` は殻がこの窓のために鋳た不透明な字で、そのタブそのものを指す(linkedPanel は使わない -- あれは再起動のたびに作り直されるので、名前が別のタブに落ちる)。`url` は current_url も宣言した drop にだけ入る(それ以外は空の字)。`values` は、その drop 自身の覚書だけ(setup() の tab_values に並べた鍵)。",
"fields": ["id", "index", "title", "url", "pinned", "selected", "hidden", "muted", "discarded", "group", "container", "values"]
},
"tab_events": {
"note": "setup() の watch = Watch(tabs = [...]) に並べられる出来事。殻が窓のタブの並びで聞いて、TabsChanged(kind, tab, tabs) という一つの action にして渡す。",
"open": { "event": "TabOpen", "ja": "タブが開いた" },
"close": { "event": "TabClose", "ja": "タブが閉じる" },
"select": { "event": "TabSelect", "ja": "タブが選ばれた" },
"move": { "event": "TabMove", "ja": "タブが動いた" },
"attr": { "event": "TabAttrModified", "ja": "タブの題などが変わった" },
"restore": { "event": "SSTabRestored", "ja": "タブが前回から戻ってきた" },
"dblclick": { "event": "dblclick", "ja": "タブが二度押しされた",
"note": "browser.tabs.closeTabByDblclick が立っているときは渡さない -- そのときは、その手つきの意味を、もう決めた人が居る。" }
},
"menus": {
"note": "`at: \"menu\"` で行を混ぜられる、本体の menupopup。行は末尾に足す(MenuSectionLayout は、どの section にも属さない子を途中で見つけると投げる。末尾の一続きだけが空いている)。`about` は、その menu が何についての menu か -- \"tab\" なら、右クリックされたタブの目印が host に写って、その行から起きる action に tab が入る。",
"tabContextMenu": { "ja": "タブの右クリック", "id": "tabContextMenu", "about": "tab" }
},
"mark_attr": {
"note": "タブの目印の、本当の名前。drop は短い名前で書き(SetTabAttr(tab, \"name\", …))、長いほうにするのは殻。生の CSS(setup().style)の中の {attr} と {var} も、殻がこの接頭辞に書き換える -- drop は自分の uuid を一度も綴らない。",
"prefix": "data-nora-<uuid>-",
"token": "{attr}",
"var_note": "目印は二つの顔で置かれる: 選ぶための属性({attr}name)と、**字を出すための変数**({var}name。CSS の文字列として、殻が引用符を付ける)。attr() は、その擬似要素が乗っている要素の属性しか読めないので、タブに付けた目印を子孫(.tab-label::before)から出すには変数のほうが要る。",
"var_prefix": "--nora-<uuid>-",
"var_token": "{var}",
"tab_holder": "data-nora-tab"
},
"tags": [
"vbox", "hbox", "box", "stack", "deck", "spacer", "separator", "splitter",
"groupbox", "caption", "scrollbox", "arrowscrollbox", "resizer", "dropmarker",
"label", "description", "image", "toolbarbutton", "toolbarseparator", "toolbaritem",
"tabbox", "tabs", "tab", "tabpanels", "tabpanel",
"listbox", "listitem", "richlistbox", "richlistitem",
"tree", "treecols", "treecol", "treechildren",
"menupopup", "panel", "tooltip", "menu", "menuitem", "menuseparator", "menulist",
"button", "checkbox", "radio", "radiogroup", "input", "textarea", "select", "option",
"div", "span", "p", "a", "img", "ul", "ol", "li", "dl", "dt", "dd",
"h1", "h2", "h3", "h4", "h5", "h6", "hr", "br", "pre", "code", "kbd",
"small", "strong", "em", "b", "i", "u", "s", "sub", "sup",
"table", "thead", "tbody", "tfoot", "tr", "th", "td",
"form", "fieldset", "legend", "details", "summary", "figure", "figcaption",
"header", "footer", "main", "nav", "section", "article", "aside",
"progress", "meter", "canvas", "svg", "path", "circle", "rect", "line", "g", "text"
],
"web_frame_tag": "browser",
"settings_page": "chrome://noraneko-settings/",
"toolbar_areas": {
"note": "`at: \"toolbar\"` で置ける場所。CustomizableUI の area の名前そのもの。ここに無い名前は nav-bar に落とす。",
"nav-bar": "アドレスバーの並び",
"TabsToolbar": "タブの並び",
"PersonalToolbar": "ブックマークバー",
"widget-overflow-fixed-list": "はみ出したものの引き出し"
},
"commands": {
"back": { "ja": "戻る" },
"forward": { "ja": "進む" },
"reload": { "ja": "読み込み直す" },
"force-reload": { "ja": "キャッシュを使わずに読み込み直す" },
"stop": { "ja": "読み込みをやめる" },
"open-home-page": { "ja": "ホームを開く" },
"open-new-tab": { "ja": "新しいタブ" },
"duplicate-tab": { "ja": "このタブを複製する" },
"close-tab": { "ja": "このタブを閉じる" },
"close-other-tabs": { "ja": "ほかのタブを全部閉じる" },
"close-tabs-to-start": { "ja": "左のタブを全部閉じる" },
"close-tabs-to-end": { "ja": "右のタブを全部閉じる" },
"reload-all-tabs": { "ja": "全部のタブを読み込み直す" },
"restore-last-tab": { "ja": "閉じたタブを戻す" },
"show-next-tab": { "ja": "次のタブへ" },
"show-previous-tab": { "ja": "前のタブへ" },
"show-previously-selected-tab": { "ja": "さっきまで見ていたタブへ" },
"show-all-tabs-panel": { "ja": "タブの一覧を出す" },
"scroll-to-selected-tab": { "ja": "選んでいるタブが先頭に来るまで、タブ帯をスクロールする" },
"show-playing-tab": { "ja": "音の鳴っているタブへ(押すたびに次へ回る)" },
"mute-current-tab": { "ja": "このタブの音を消す / 戻す" },
"restore-last-window": { "ja": "閉じた窓を戻す" },
"restore-last-session": { "ja": "前回のセッションを戻す" },
"zoom-in": { "ja": "大きくする" },
"zoom-out": { "ja": "小さくする" },
"reset-zoom": { "ja": "大きさを戻す" },
"enter-into-customize-mode": { "ja": "ツールバーを組み替える" },
"enter-into-offline-mode": { "ja": "オフラインにする / 戻す" },
"bookmark-this-page": { "ja": "このページをブックマークする" },
"open-bookmark-add-tool": { "ja": "開いているタブを全部ブックマークする" },
"open-bookmarks-manager": { "ja": "ブックマークの整理" },
"toggle-bookmark-toolbar": { "ja": "ブックマークバーを出す / 隠す" },
"show-bookmark-sidebar": { "ja": "ブックマークをサイドバーに出す" },
"search-history": { "ja": "履歴を探す" },
"manage-history": { "ja": "履歴の整理" },
"show-history-sidebar": { "ja": "履歴をサイドバーに出す" },
"forget-history": { "ja": "履歴を消す(窓を出す)" },
"quick-forget-history": { "ja": "履歴をすぐ消す" },
"save-page": { "ja": "ページを保存する" },
"print-page": { "ja": "印刷する" },
"show-source-of-page": { "ja": "ソースを見る" },
"show-page-info": { "ja": "ページの情報" },
"send-with-mail": { "ja": "リンクをメールで送る" },
"open-screen-capture": { "ja": "スクリーンショットを撮る" },
"search-in-this-page": { "ja": "このページの中を探す" },
"show-next-search-result": { "ja": "次を探す" },
"show-previous-search-result": { "ja": "前を探す" },
"search-the-web": { "ja": "ウェブを検索する" },
"show-synced-tabs-sidebar": { "ja": "同期したタブをサイドバーに出す" },
"reverse-sidebar": { "ja": "サイドバーを左右で入れ替える" },
"hide-sidebar": { "ja": "サイドバーを閉じる" },
"open-general-preferences": { "ja": "設定を開く" },
"open-privacy-preferences": { "ja": "設定のプライバシーを開く" },
"open-containers-preferences": { "ja": "設定のコンテナーを開く" },
"open-search-preferences": { "ja": "設定の検索を開く" },
"open-sync-preferences": { "ja": "設定の同期を開く" },
"open-addons-manager": { "ja": "アドオンマネージャを開く" },
"open-downloads": { "ja": "ダウンロードの履歴を開く" },
"open-task-manager": { "ja": "プロセスの一覧を開く" },
"open-migration-wizard": { "ja": "ほかのブラウザから移行する" }
},
"browser_pages": {
"note": "`<browser>` に `page => \"bookmarks\"` と書いたとき、殻が読み込む先。ここに無い名前は読み込まない。http でない先は remote にしない(chrome の文書は、その窓では開けない)。",
"bookmarks": { "ja": "ブックマーク", "url": "chrome://browser/content/places/bookmarksSidebar.xhtml" },
"history": { "ja": "履歴", "url": "chrome://browser/content/places/historySidebar.xhtml" },
"downloads": { "ja": "ダウンロード", "url": "about:downloads" },
"library": { "ja": "ライブラリ", "url": "chrome://browser/content/places/places.xhtml" },
"synced-tabs": { "ja": "同期したタブ", "url": "chrome://browser/content/syncedtabs/sidebar.xhtml" }
},
"key_tag": "key",
"key_combo": {
"note": "押しかたは一つの字で書く(\"Accel+Alt+Z\")。keycode / key / modifiers に綴り直すのは殻。Accel は、どこでも同じ指(Windows と Linux では Ctrl、macOS では Cmd)。",
"modifiers": {
"accel": "accel",
"ctrl": "control",
"control": "control",
"alt": "alt",
"option": "alt",
"shift": "shift",
"meta": "meta",
"cmd": "meta"
},
"named": {
"f1": "VK_F1", "f2": "VK_F2", "f3": "VK_F3", "f4": "VK_F4",
"f5": "VK_F5", "f6": "VK_F6", "f7": "VK_F7", "f8": "VK_F8",
"f9": "VK_F9", "f10": "VK_F10", "f11": "VK_F11", "f12": "VK_F12",
"enter": "VK_RETURN", "return": "VK_RETURN",
"escape": "VK_ESCAPE", "esc": "VK_ESCAPE",
"space": "VK_SPACE", "tab": "VK_TAB",
"delete": "VK_DELETE", "backspace": "VK_BACK", "insert": "VK_INSERT",
"home": "VK_HOME", "end": "VK_END",
"pageup": "VK_PAGE_UP", "pagedown": "VK_PAGE_DOWN",
"up": "VK_UP", "down": "VK_DOWN", "left": "VK_LEFT", "right": "VK_RIGHT"
}
},
"props": {
"note": "view が書ける属性。ここに無いものは落として console に一行。`style` はデータ(style.ts が印字する)、`on:*` は行事、どちらもこの表の外。",
"any": [
"id", "class", "hidden", "disabled", "collapsed", "key",
"label", "value", "tooltiptext", "title", "image", "src", "href", "alt",
"flex", "align", "pack", "orient", "dir", "crop", "type", "placeholder",
"width", "height", "minwidth", "minheight", "maxwidth", "maxheight",
"checked", "selected", "context", "accesskey", "acceltext", "colspan", "rowspan"
],
"prefixes": ["aria-"],
"url_valued": ["src", "href", "image"],
"url_schemes": ["https:", "http:", "resource:", "moz-extension:", "page-icon:"],
"url_prefixes": ["chrome://global/skin/", "chrome://browser/skin/"]
}
}
lib/commands.ts · 150 lines
// SPDX-License-Identifier: MPL-2.0
// ブラウザ自身の命令の表。`DoCommand("back")` の "back" が、ここで一つの
// 関数になる。
//
// 出どころは Floorp の mouse-gesture/utils/actions.ts(94 個)。名前はそこから
// `gecko-` を落としただけで、中身も同じものを呼んでいる -- 一度ここに書いて
// 一度審査されれば、以後どの drop も **宣言に一行足すだけ**で使える。
// drop ごとに JS を書くのとの差が、いちばん大きく出るところ。
//
// Floorp に無いものも、ここに一つずつ足す(`show-previously-selected-tab` と
// `scroll-to-selected-tab`、`show-playing-tab`)。書くのは「本体の API を組んで一つの動作にする」
// までで、新しい口を開けることはしない。
//
// 三つのきまり:
//
// - **宣言に無い名前は実行しない**(殻が断る。tsubakiActor.ts の perform)
// - **表に無い名前も実行しない**。drop が綴りを間違えても、ブラウザの
// 知らない口が開くことはない
// - どれも **いま見ている窓**に効く。窓を選ぶ道は、ここには無い
//
// 入れなかったもの(Floorp の 74 のうち 16): ページを scroll する八つは content の
// actor が要る(段 7)、workspace の二つと toggle-sidebar と workspaces の設定は
// Floorp 自身の機能で gecko ではない、そして窓を開け閉めする四つは別の宣言にする。
//
// 呼び先が無い版の Firefox もありうるので、呼ぶのは try の中(tsubakiActor.ts)。
// 落ちたときは console に一行で、view も他の effect も止めない。
/** A chrome window, as far as this table is concerned. */
// deno-lint-ignore no-explicit-any
type Win = any;
/** ツールバーのボタンを、そこにあれば押す(戻る・進む・再読み込み) */
function doCommand(win: Win, id: string): void {
const el = win.document.getElementById(id);
if (el?.doCommand) el.doCommand();
else console.warn("[command] この窓に無いボタン:", id);
}
/** いま見ているものの次に新しく見られていたタブ */
function lastSeen(win: Win): void {
let latest = null;
for (const tab of win.gBrowser.tabs) {
if (tab._lastAccessed === Infinity || tab === win.gBrowser.selectedTab) continue;
if (!latest || tab._lastAccessed > latest._lastAccessed) latest = tab;
}
if (latest) win.gBrowser.selectedTab = latest;
}
/**
* いま選ばれているタブが、タブ帯の**先頭**に来るまで視界を動かす。
* 並びは変えない -- 動くのはスクロールの位置だけ。
*
* 先頭の縁は、横なら左、縦なら上、RTL なら右。三つとも箱が知っているので、
* こちらで縦横を数えない(arrowscrollbox.js の startEndProps / isRTLScrollbox)。
* 渡すのは相対の差なので、smooth scroll が走っている途中でも辻褄が合う。
*/
function scrollToSelected(win: Win): void {
const tab = win.gBrowser.selectedTab;
if (!tab?.visible) return; // 畳んだ group の中、あるいは仕舞われている
const box = tab.closest("arrowscrollbox"); // 固定タブは別の箱に居る(本体と同じ決めかた)
if (!box?.overflowing) return; // はみ出していなければ、動かすところが無い
const edge = box.isRTLScrollbox ? box.startEndProps[1] : box.startEndProps[0];
box.scrollByPixels(tab.getBoundingClientRect()[edge] - box.scrollClientRect[edge]);
}
/**
* 音の鳴っているタブへ。**押すたびに次へ回る** -- いま選んでいるタブの次から
* 探して、末尾まで行ったら先頭に戻る(`show-next-tab` と同じ回りかた)。どこまで
* 回ったかを覚えないので、タブが増えても減っても迷子にならない。
*
* 黙らせたタブは飛ばす。`soundplaying` は muted でも付く(タブの絵が「ミュート中
* だが鳴っている」を出し分けるため)けれど、探しているのは**聞こえている音**のほう。
* 仕舞われたタブも飛ばす(visibleTabs)。
*/
function playingTab(win: Win): void {
const tabs = win.gBrowser.visibleTabs;
const here = tabs.indexOf(win.gBrowser.selectedTab);
for (let i = 1; i <= tabs.length; i++) {
const tab = tabs[(here + i) % tabs.length];
if (tab.soundPlaying && !tab.muted) {
win.gBrowser.selectedTab = tab;
return;
}
}
}
export const COMMANDS: Record<string, (win: Win) => void> = {
"back": (win) => doCommand(win, "back-button"),
"forward": (win) => doCommand(win, "forward-button"),
"reload": (win) => doCommand(win, "reload-button"),
"force-reload": (win) => win.BrowserCommands.reloadSkipCache(),
"stop": (win) => win.gBrowser.selectedBrowser.stop(),
"open-home-page": (win) => win.switchToTabHavingURI(Services.prefs.getStringPref("browser.startup.homepage"), true),
"open-new-tab": (win) => win.BrowserCommands.openTab(),
"duplicate-tab": (win) => win.gBrowser.duplicateTab(win.gBrowser.selectedTab),
"close-tab": (win) => win.gBrowser.removeCurrentTab({ animate: true }),
"close-other-tabs": (win) => win.gBrowser.removeTabs(win.gBrowser.visibleTabs.filter((t) => t !== win.gBrowser.selectedTab)),
"close-tabs-to-start": (win) => win.gBrowser.removeTabsToTheStartFrom(win.gBrowser.selectedTab),
"close-tabs-to-end": (win) => win.gBrowser.removeTabsToTheEndFrom(win.gBrowser.selectedTab),
"reload-all-tabs": (win) => win.gBrowser.reloadTabs(win.gBrowser.tabs),
"restore-last-tab": (win) => win.SessionStore.undoCloseTab(win, 0),
"show-next-tab": (win) => win.gBrowser.tabContainer.advanceSelectedTab(1, true),
"show-previous-tab": (win) => win.gBrowser.tabContainer.advanceSelectedTab(-1, true),
"show-previously-selected-tab": (win) => lastSeen(win),
"show-all-tabs-panel": (win) => win.gTabsPanel.showAllTabsPanel(),
"scroll-to-selected-tab": (win) => scrollToSelected(win),
"show-playing-tab": (win) => playingTab(win),
"mute-current-tab": (win) => win.gBrowser.toggleMuteAudioOnMultiSelectedTabs(win.gBrowser.selectedTab),
"restore-last-window": (win) => win.SessionWindowUI.undoCloseWindow(0),
"restore-last-session": (win) => win.SessionStore.restoreLastSession(),
"zoom-in": (win) => win.FullZoom.enlarge(),
"zoom-out": (win) => win.FullZoom.reduce(),
"reset-zoom": (win) => win.FullZoom.reset(),
"enter-into-customize-mode": (win) => win.gCustomizeMode.enter(),
"enter-into-offline-mode": (win) => win.BrowserOffline.toggleOfflineStatus(),
"bookmark-this-page": (win) => win.PlacesCommandHook.bookmarkPage(),
"open-bookmark-add-tool": (win) => win.PlacesUIUtils.showBookmarkPagesDialog(win.PlacesCommandHook.uniqueCurrentPages),
"open-bookmarks-manager": (win) => win.SidebarController.toggle("viewBookmarksSidebar"),
"toggle-bookmark-toolbar": (win) => win.BookmarkingUI.toggleBookmarksToolbar("bookmark-tools"),
"show-bookmark-sidebar": (win) => win.SidebarController.show("viewBookmarksSidebar"),
"search-history": (win) => win.PlacesCommandHook.searchHistory(),
"manage-history": (win) => win.PlacesCommandHook.showPlacesOrganizer("History"),
"show-history-sidebar": (win) => win.SidebarController.show("viewHistorySidebar"),
"forget-history": (win) => win.Sanitizer.showUI(win),
"quick-forget-history": (win) => win.PlacesUtils.history.clear(true),
"save-page": (win) => win.saveBrowser(win.gBrowser.selectedBrowser),
"print-page": (win) => win.PrintUtils.startPrintWindow(win.gBrowser.selectedBrowser.browsingContext),
"show-source-of-page": (win) => win.BrowserViewSource(win.gBrowser.selectedBrowser),
"show-page-info": (win) => win.BrowserCommands.pageInfo(),
"send-with-mail": (win) => win.MailIntegration.sendLinkForBrowser(win.gBrowser.selectedBrowser),
"open-screen-capture": (win) => win.ScreenshotsUtils.start(win.gBrowser.selectedBrowser),
"search-in-this-page": (win) => win.gLazyFindCommand("onFindCommand"),
"show-next-search-result": (win) => win.gLazyFindCommand("onFindAgainCommand", false),
"show-previous-search-result": (win) => win.gLazyFindCommand("onFindAgainCommand", true),
"search-the-web": (win) => win.BrowserSearch.webSearch(),
"show-synced-tabs-sidebar": (win) => win.SidebarController.show("viewTabsSidebar"),
"reverse-sidebar": (win) => win.SidebarController.reversePosition(),
"hide-sidebar": (win) => win.SidebarController.hide(),
"open-general-preferences": (win) => win.openPreferences(),
"open-privacy-preferences": (win) => win.openPreferences("panePrivacy"),
"open-containers-preferences": (win) => win.openPreferences("paneContainers"),
"open-search-preferences": (win) => win.openPreferences("paneSearch"),
"open-sync-preferences": (win) => win.openPreferences("paneSync"),
"open-addons-manager": (win) => win.BrowserAddonUI.openAddonsMgr(),
"open-downloads": (win) => win.DownloadsPanel.showDownloadsHistory(),
"open-task-manager": (win) => win.switchToTabHavingURI("about:processes", true),
"open-migration-wizard": (win) => win.MigrationUtils.showMigrationWizard(win, { entrypoint: win.MigrationUtils.MIGRATION_ENTRYPOINTS.FILE_MENU }),
};
lib/index.ts · 31 lines
// SPDX-License-Identifier: MPL-2.0
// std-actor: drop の**殻**。
//
// actor.ts を書かない drop(logic も actor も Tsubaki)の中身は、どれも同じです ──
// logic の三つの door(setup / start / dispatch)を呼んで、返ってきた view を描き、
// effect を carry out する。前はその殻が **どの drop の xpi にも一枚ずつ**入って
// いました(content.js の 63KB のうち、ほとんどがこれ)。同じ bytes を十九枚配って
// いたことになるし、入れる人が読むものも、自分の drop の 3KB のために殻の 15 枚を
// めくる形になっていました。
//
// lib にすると、配られるのは一枚だけ。drop の xpi に残るのは、その drop 自身の
// 宣言と logic と、殻を呼ぶ数行になります。
//
// 版が上がっても、**すでに組んである drop は組み直すまで前の版のまま**です
// (deps の版は使う側が build したときに固定される)。何と何が合うのかは台帳に残る。
export { runTsubakiActor } from "./tsubakiActor.ts";
// 殻を呼ぶところ(ctx を組んで hook を呼ぶ)。どの drop でも同じ bytes なので、
// ここから配る -- 自分で actor.ts を書く drop も、生えた actor.ts も、同じものを使う。
// source は tooling/webext-actors/_shared/(noraneko と分け合っているもの)のまま。
export { runContent } from "../_shared/contentRuntime.ts";
// 殻の部品。自分で actor.ts を書く drop が、同じ言葉で view を組みたいとき用。
export { allowProp, toPreact } from "./vnode.ts";
export type { Action, VNode, ViewPolicy } from "./vnode.ts";
export { classOf, declarations, isNested, makeSheet, printStyle, rule } from "./style.ts";
export type { Sheet, StyleData } from "./style.ts";
export { COMMANDS } from "./commands.ts";
lib/style.ts · 208 lines
// SPDX-License-Identifier: MPL-2.0
// style をデータで受け取って、CSS にする。
//
// **ここが、CSS の字を書く唯一の場所。** drop は一度も CSS を綴らない -- 綴らない
// から、「外に電話できない」を走査で守らなくていい。**印字できるものしか印字しない**、
// それだけで守られる(`url(...)` の書きかたが、そもそも無い)。
//
// Dict(:width => 40px, :gap => 2px) → 要素の style 属性
// Dict(:width => 40px, ":hover" => Dict(...)) → class 一つと、規則一つ
//
// 読みかた:
//
// 鍵が名前 property。`_` は `-`(頭の `_` は `-moz-` などの頭の `-`)
// 鍵が selector 入れ子。`:` `[` `>` `+` `~` `&` 空白 `@` で始まるもの
// 数 px。単位を持たない property(下の UNITLESS)だけ素の数
// Len(40, :px) `40px`。`:pct` は `%`
// 文字列 / Symbol そのまま(Symbol は JSON を渡るとき文字列になる。同じもの)
// 並び 空白で連ねる(`(1px, :solid, Theme.separator)`)。読点が要る
// ものは、文字列で書く("Arial, sans-serif")
//
// 入れ子が無ければ style 属性に置く(sheet が育たない)。有れば**中身から class の
// 名前を作って**、規則を一枚の <style> に足す。同じ style は同じ class になるので、
// 状態で描き替えても増えない。
/** 一枚ぶんの style。値は数 / Len / 文字列 / 並び / 入れ子 */
export type StyleData = Record<string, unknown>;
export function isStyleData(v: unknown): v is StyleData {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/** 素の数で書く property(px を足さない) */
const UNITLESS = new Set([
"opacity", "order", "z-index", "flex", "flex-grow", "flex-shrink", "line-height",
"font-weight", "zoom", "column-count", "columns", "orphans", "widows", "tab-size",
"aspect-ratio", "scale", "animation-iteration-count", "grid-row", "grid-column",
"grid-row-start", "grid-row-end", "grid-column-start", "grid-column-end",
"fill-opacity", "stroke-opacity", "stroke-width", "stroke-dashoffset", "stroke-miterlimit",
]);
/** 長さの単位。知らない単位は印字しない */
const UNITS: Record<string, string> = {
px: "px", pct: "%", em: "em", rem: "rem", ch: "ch", ex: "ex",
vh: "vh", vw: "vw", vmin: "vmin", vmax: "vmax", fr: "fr",
s: "s", ms: "ms", deg: "deg", turn: "turn",
};
/** 値の中に書けない字。閉じ括弧や `;` は、一つの宣言から外へ出る道になる */
const NOT_IN_VALUE = /[;{}<>\\]|url\s*\(|image-set\s*\(|\/\*|expression\s*\(/i;
/** selector に書けない字。`{` `}` `;` と、@import(外を読みに行く) */
const NOT_IN_SELECTOR = /[{};]|\/\*|@import/i;
const SELECTOR_HEAD = /^[:\[>+~&\s@]/;
function warn(what: string, ...rest: unknown[]): void {
console.warn(`[style] ${what}`, ...rest);
}
/** 鍵 → property の名前。`min_width` → `min-width`、`_moz_x` → `-moz-x` */
function propertyName(key: string): string | null {
const name = key.replace(/_/g, "-");
if (!/^-{0,2}[a-zA-Z][a-zA-Z0-9-]*$/.test(name)) {
warn("property の名前として読めない:", key);
return null;
}
return name;
}
/** 一つの値 → CSS の字。印字できないものは null(そのまま落とす) */
function valueText(property: string, v: unknown): string | null {
if (v === null || v === undefined || typeof v === "boolean") return null;
if (typeof v === "number") {
if (!Number.isFinite(v)) return null;
return UNITLESS.has(property) ? String(v) : `${v}px`;
}
if (typeof v === "string") {
if (NOT_IN_VALUE.test(v)) {
warn("値に書けない字がある(url( や ; など):", property, v);
return null;
}
return v;
}
if (Array.isArray(v)) {
const parts = v.map((one) => valueText(property, one));
if (parts.some((p) => p === null)) return null;
return parts.join(" ");
}
if (isStyleData(v) && v.__type === "Len") {
const unit = UNITS[String(v.unit)];
if (unit === undefined) {
warn("知らない単位:", v.unit);
return null;
}
const n = v.n;
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return `${n}${unit}`;
}
warn("印字できない値:", property, v);
return null;
}
/** この鍵は「入れ子の selector」か */
function isSelector(key: string): boolean {
return SELECTOR_HEAD.test(key);
}
/** 入れ子を持つか(持てば class、持たなければ style 属性) */
export function isNested(data: StyleData): boolean {
return Object.keys(data).some(isSelector);
}
/** 平らな宣言だけ → `width: 40px; gap: 2px` */
export function declarations(data: StyleData): string {
const out: string[] = [];
for (const key of Object.keys(data)) {
if (key === "__type" || isSelector(key)) continue;
const property = propertyName(key);
if (!property) continue;
const text = valueText(property, data[key]);
if (text === null) continue;
out.push(`${property}: ${text}`);
}
return out.join("; ");
}
/** 一枚の規則(入れ子は CSS nesting でそのまま出す) */
export function rule(selector: string, data: StyleData): string {
if (NOT_IN_SELECTOR.test(selector)) {
warn("selector に書けない字がある:", selector);
return "";
}
const body: string[] = [];
const decls = declarations(data);
if (decls) body.push(` ${decls};`);
for (const key of Object.keys(data)) {
if (!isSelector(key)) continue;
const inner = data[key];
if (!isStyleData(inner)) {
warn("入れ子の中身が style ではない:", key);
continue;
}
const nested = rule(key.startsWith("@") ? key : `&${key.trimStart() === key ? "" : " "}${key.trim()}`, inner);
if (nested) body.push(nested.split("\n").map((l) => ` ${l}`).join("\n"));
}
if (body.length === 0) return "";
return `${selector} {\n${body.join("\n")}\n}`;
}
/** 中身から名前を作る(同じ style は同じ class)。FNV-1a */
export function classOf(data: StyleData): string {
const text = canonical(data);
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return `nora-${h.toString(36)}`;
}
/** 鍵の並びで答えが変わらないように、並べてから字にする */
function canonical(v: unknown): string {
if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`;
if (isStyleData(v)) {
return `{${Object.keys(v).sort().map((k) => `${k}:${canonical(v[k])}`).join(",")}}`;
}
return JSON.stringify(v) ?? "null";
}
/** 規則を置いていく先。同じ class は一度だけ書く */
export interface Sheet {
/** その style の class 名(規則はまだ無ければ、ここで足される) */
classFor(data: StyleData): string;
}
export function makeSheet(add: (css: string) => void): Sheet {
const seen = new Set<string>();
return {
classFor(data) {
const name = classOf(data);
if (!seen.has(name)) {
seen.add(name);
const css = rule(`.${name}`, data);
if (css) add(css);
}
return name;
},
};
}
/** props の style を、要素に渡せる形にする */
export function printStyle(
data: StyleData,
sheet: Sheet | undefined,
): { style?: string; class?: string } {
if (!isNested(data)) {
const decls = declarations(data);
return decls ? { style: decls } : {};
}
if (!sheet) {
// 入れ子は class が要る。置き場所が無いときは、平らなところだけでも出す
warn("入れ子のある style を置く先が無い(平らなところだけ置く)");
const decls = declarations(data);
return decls ? { style: decls } : {};
}
return { class: sheet.classFor(data) };
}
lib/tsubakiActor.ts · 1135 lines
// SPDX-License-Identifier: MPL-2.0
// A drop whose actor is written in Tsubaki: no actor.ts at all, just
// ops/*.tsubaki and the `[actor]` table in drop.toml. This is the shell the
// build puts around it -- the same shell for every such drop, so what a
// reviewer reads is the drop's own words and not its plumbing.
//
// The three doors the logic answers:
//
// setup() -> Dict: where to put the view, its style, which prefs to follow
// start(facts) -> Frame: the first view (facts = what was already true)
// dispatch(action) -> Frame: the next view, and what to do about it
//
// A Frame is Dict("view" => …, "effects" => [...]). The view is one VNode when
// there is one place to put it, or Dict(anchor の名前 => VNode) when setup asked
// for several (a strip beside the tabs AND a <menupopup> under #mainPopupSet
// cannot be one tree: they live in different parts of the window).
//
// Effects are data, made there and carried out here -- `perform` below is the
// whole vocabulary, and it is meant to stay small: a drop that needs more than
// this writes an actor.ts (that door is not closing).
//
// Two of them are the logic asking a question rather than giving an order.
// `Ask` and `Measure` exist because a rule the logic keeps is that it never
// makes a fact: a fresh uuid, the URL of the tab you are on, the width a box
// actually ended up after a drag. So instead of the shell quietly filling a
// hole in an action, the logic says which facts it wants and what to call the
// action they come back in -- and the answer arrives as an ordinary dispatch,
// the same door everything else from outside comes through.
//
// The view's own vocabulary -- which TAGS it may name -- is held in vnode.ts.
// It is a list, not a filter on the way out: what a reviewer reads is the
// drop's data plus a shell whose whole reach is written down. `<browser>` (a
// window that loads a web page) is not in it unless the drop declared
// `web_frame` in drop.toml, and even then the shell, not the drop, decides what
// kind of window it is. `<key>` is the same shape of promise from the other
// side: it draws nothing, it only listens, and a drop gets one for each key
// combination it wrote down in drop.toml -- an undeclared one is left out
// alone, and the rest of the view goes up as it is.
//
// Everything placed goes through ctx.io / mount, so removing the drop takes the
// views, the style and the pref observers back out by itself. What mount places
// also moves atomically (std-preact-xul), so a redraw that reorders the view
// does not quietly reload the page inside a <browser>.
// 使う lib は、umbrella(std)ではなく本物の名前で呼ぶ -- std は std-actor を
// 連れてくる側なので、こちらが std を呼ぶと輪になる
import { h, mount, signal, useSignalValue, type ReadonlySignal } from "std-preact-xul";
import { placeMenuRows } from "std-context-menu";
import { settingsPlace } from "std-settings";
// 書く人の側の型(tooling/webext-actors/_shared/。noraneko と分け合っているもの)。
// 組むのは _stage/<name>/ の中なので、そこでは lib/ の隣に _shared/ が居る
import type { ContentCtx } from "../_shared/defineActor.ts";
import { toPreact, type Action, type VNode, type ViewPolicy } from "./vnode.ts";
import { makeSheet, type Sheet } from "./style.ts";
import abi from "../abi.json" with { type: "json" };
import { COMMANDS } from "./commands.ts";
/** 設定の頁(about:nora:settings)。ここだけが `at: "settings"` の置き場所 */
const SETTINGS_PAGE: string = abi.settings_page;
interface Anchor {
/** the name `view` answers with when there are several. The only one may leave it out ("main"). */
name?: string;
/**
* where, relative to what the selector found: after / before / inside it.
*
* `"keyset"` is the one place that is not a selector: the window's own keyset,
* where a <key> has to be a direct child for Firefox to look at it at all.
* The shell stands this drop's own <keyset> next to #mainKeyset, so the keys
* go away with the drop and nothing of the browser's is edited.
*
* `"settings"` is the other one: the drop's own page in about:nora:settings.
* A drop that asks for it says so twice -- here, and by matching the settings
* page as well as the window in drop.toml -- and then the same logic is
* answering in two documents at once. They do not share memory; they share
* prefs, which is how two windows already agree with each other.
*
* `"toolbar"` is a place people can move. The shell makes one CustomizableUI
* widget for the drop and hands each window the instance that belongs to it,
* so where someone drags it in customize mode is remembered by the browser --
* not by the drop, which never learns where it ended up.
*
* `"menu"` は、**本体の** menupopup の中。`menu` でどれかを名指しして
* (abi の menus)、行はその popup の末尾に足される。本体の行は動かさない。
* その menu が「何についての menu か」を abi が知っているので、右クリック
* されたタブの目印が host に写る -- drop はその目印で CSS を書けて、popup が
* 塗られる前に worker を往復しなくていい。
*/
at?: "after" | "before" | "parent" | "keyset" | "settings" | "toolbar" | "menu";
/** `at: "toolbar"` only: which CustomizableUI area to start in (abi の toolbar_areas) */
area?: string;
/** `at: "menu"` only: which of the browser's menupopups (abi の menus) */
menu?: string;
/** a CSS selector in this document. Defaults to "body". */
selector?: string;
/** the host element's tag ("vbox", "hbox", "menupopup", "html:div", ...) */
tag?: string;
id?: string;
}
interface Setup {
/** one place for the view. `anchors` says it for several. */
anchor?: Anchor;
anchors?: Anchor[];
style?: string;
/** prefs to read at start and follow: a change raises PrefChanged(name, value) */
prefs?: string[];
/**
* The same, for a pref whose string holds JSON: it is read as the value it
* spells, and SetPref on one of these writes the value back as JSON. A list
* that has to keep someone else's shape (a setting another add-on already
* writes) is the reason this exists; a setting of one's own is one pref.
*/
prefs_json?: string[];
/**
* 見ていてほしい出来事。いまタブだけ(abi の tab_events)。一つでも並べたら
* drop.toml に `tabs = "read"` が要る -- 出来事にはタブそのものが乗ってくるので。
*/
watch?: { tabs?: string[] };
/**
* 読み戻す覚書の鍵。SessionStore の per-tab value は鍵で一つずつ訊くものなので、
* 「どの鍵を持っている drop か」を先に言ってもらう。鍵を長くする
* (`nora.<uuid>.<鍵>`)のは殻で、`tab_values` の宣言が要る。
*/
tab_values?: string[];
/**
* 同じことを、窓に。置き場所が per-window value になるだけで、作法は同じ
* (鍵を長くするのは殻、`window_values` の宣言が要る)。start(facts) の
* `window` に、並べた鍵のぶんが入って届く。
*/
window_values?: string[];
/**
* 前回から戻ってくるタブを、どう迎えるか。**logic には訊かない**ので、
* ここがデータで要る(下の bakeOnRestore に、なぜかが書いてある)。
*/
restore?: Restore;
}
/**
* 戻ってくるタブに、覚書から焼くもの。
*
* `marks`: その鍵の覚書を持っているタブに、同じ名前の目印を同じ値で。
* `hide_unless`: その鍵の覚書が **窓の覚書**と違うタブは、仕舞ったまま戻す
* (束で仕舞う drop の、いちばん大事な一行。`tabs = "write"` が要る)。
*/
interface Restore {
marks?: string[];
hide_unless?: string;
}
interface Frame {
/** one VNode (it goes to the first anchor), or the anchor's name => its VNode */
view: VNode | Record<string, VNode | null> | null;
effects?: Action[];
}
export async function runTsubakiActor(
ctx: ContentCtx,
policy: ViewPolicy = {},
): Promise<void> {
const ops = ctx.ops;
if (!ops) throw new Error("a Tsubaki actor needs std's runtime (ctx.ops)");
// --- したことを残す ------------------------------------------------------
//
// effect を carry out するたび、一行残す。捕まえるためではなく、あとで辿れる
// ように -- 信頼して入れる、というのは見ないことにするのではなくて、あとで
// 見られるから安心して入れられる、ということだと思う。
//
// observer notification で出すので、drop は drop のままでいられる(記録のために
// 本体の何かを import しない)し、誰も聞いていなくても何も壊れない。
const note = (did: string, about: string, extra?: Record<string, unknown>): void => {
try {
Services.obs.notifyObservers(
null,
"nora-drop-did",
JSON.stringify({ drop: policy.name ?? "?", uuid: policy.uuid ?? "?", at: Date.now(), did, about, ...extra }),
);
} catch {
// 記録が取れないことで、drop が止まらないように
}
};
const brief = (v: unknown): string => {
const t = typeof v === "string" ? v : JSON.stringify(v) ?? String(v);
return t.length > 60 ? t.slice(0, 60) + "…" : t;
};
// 宣言していないことは、殻が断る。断ったときは黙らない -- 入れる人の画面に
// 出ている行と、実際にできることが違ったら、それはどちらかが嘘なので。
//
// 断りかたは「だめ」ではなく「こう書けば通る」。drop.toml にそのまま貼れる行を
// 出す(同じことは一度だけ)。書いている最中に、宣言を探しに行かなくていいように。
const told = new Set<string>();
const refuse = (what: string, permission: string, line?: string): void => {
note("outside", what, { permission });
if (told.has(permission + ":" + what)) return;
told.add(permission + ":" + what);
console.warn(
`[${policy.name ?? "tsubaki-actor"}] ${what} は、まだ宣言していません\n` +
` drop.toml に足すなら:\n\n [permissions]\n ` +
(line ?? (permission.includes("=") ? permission : `${permission} = true`)) + `\n`,
);
};
// 字を選ぶ。drop は鍵で書き(std の `t(:add)`)、どのロケールのどの字になるかは
// ここで決まる。ブラウザの言語 → その言語 → en の順に見て、最初に有ったもの。
// 無い鍵は鍵そのものが出る(黙って消えるより、出ているほうが直せる)。
const strings = (policy as { strings?: Record<string, Record<string, string>> }).strings ?? {};
const wanted = Services.locale.appLocaleAsBCP47 ?? "en";
const table = strings[wanted] ?? strings[wanted.split("-")[0]] ?? strings.en ?? {};
const viewPolicy: ViewPolicy = { ...policy, text: { ...(strings.en ?? {}), ...table } };
// 自分の名前空間(noraneko.<drop の名前>.)は名指しが要らない。それ以外は名指しだけ。
const named = new Set(policy.prefs ?? []);
const mayTouchPref = (name: string): boolean => {
if (policy.ownPrefix && name.startsWith(policy.ownPrefix)) return true;
if (named.has(name)) return true;
refuse(
`pref "${name}"`,
"prefs",
`prefs = ${JSON.stringify([...named, name].filter((n, i, all) => all.indexOf(n) === i))}`,
);
return false;
};
// A browser window: wait until it is a whole one (gBrowser and the rest), and
// leave the ones that are not really windows alone -- a popup opened with
// window.open has no toolbar to put anything next to. Neither is true of an
// ordinary page, where both of these are simply absent.
const win = window as unknown as { delayedStartupPromise?: Promise<void> };
if ((document.documentElement.getAttribute("chromehidden") ?? "").includes("toolbar")) return;
if (win.delayedStartupPromise) await win.delayedStartupPromise;
const setup = ((await ops.call("setup")) ?? {}) as Setup;
const anchors = setup.anchors ?? [setup.anchor ?? {}];
// 同じ logic が、窓と設定の頁の両方に居ることがある。置き場所のほうは
// 行き先が決まっているので、ここで分ける -- 窓の #browser は設定の頁に無いし、
// 設定の一枚は窓に出しても意味が無い。view は、どちらでも同じ名前で答える。
const onSettings = String(document.location?.href ?? "").startsWith(SETTINGS_PAGE);
const belongsHere = (a: Anchor) => (a.at === "settings") === onSettings;
// already here: this window was done once (an actor can be asked twice)
for (const a of anchors) if (a.id && document.getElementById(a.id)) return;
const names = anchors.map((a, i) => {
if (a.name) return a.name;
if (anchors.length === 1) return "main";
throw new Error(`setup: anchors[${i}] に "name" が無い(view はその名前で答える)`);
});
const asJson = new Set((setup.prefs_json ?? []).filter(mayTouchPref));
const watched = [...(setup.prefs ?? []).filter(mayTouchPref), ...asJson];
const readOne = (name: string) => (asJson.has(name) ? readJsonPref(name) : readPref(name));
const readAll = () => {
const out: Record<string, unknown> = {};
for (const name of watched) out[name] = readOne(name);
return out;
};
// one signal, one entry per anchor: a frame is one view of the whole drop,
// even when it is drawn in two places
const views = signal<Record<string, VNode | null>>({});
const hosts: Element[] = [];
// style がデータで来たときの置き場所。**CSS の字を書くのは style.ts だけ**で、
// ここは置き場所を持っているだけ。一枚も要らなければ <style> も作らない。
// 作ったものは ctx.io.style の台帳に載るので、drop を外すと一緒に消える。
let sheetEl: HTMLStyleElement | null = null;
const sheet: Sheet = makeSheet((css) => {
if (!sheetEl) sheetEl = ctx.io.style(document, "");
sheetEl.textContent += `${css}\n`;
});
// --- タブ ---------------------------------------------------------------------
// タブは、この drop が置いたものではない。だから殻が渡すのは「読んだこと」と、
// **自分が付けた**目印と覚書の付け外しだけ。タブそのものは渡さない。
//
// `id` は、この窓のために鋳った不透明な字。`linkedPanel` は使わない -- あれは
// 起動のたびに作り直されるので、覚えた名前が別のタブに落ちる(rename-tab が
// 一度それで失敗している)。WeakMap なので、タブが閉じれば id も一緒に消える。
const MARK = `data-nora-${policy.uuid ?? ""}-`;
// 目印は、二つの顔で置く。**選ぶための属性**と、**字を出すための変数**。
// `attr()` は、その擬似要素が乗っている要素の属性しか読めないので、タブに付けた
// 目印を `.tab-label::before` から出すには、継ぐほう(custom property)が要る
// ── 実機で一度、名前が空で出た。値は CSS の文字列として置く(引用符は殻が付ける)。
const MARK_VAR = `--nora-${policy.uuid ?? ""}-`;
const VALUE = `nora.${policy.uuid ?? ""}.`;
const NAME_OK = /^[a-z0-9][a-z0-9-]*$/;
const XHTML = "http://www.w3.org/1999/xhtml";
const tabIds = new WeakMap<Element, string>();
let minted = 0;
const idOf = (tab: Element): string => {
const found = tabIds.get(tab);
if (found) return found;
const id = `tab${++minted}`;
tabIds.set(tab, id);
return id;
};
const browser = () =>
(window as unknown as {
gBrowser?: { tabs?: ArrayLike<Element>; selectedTab?: Element; tabContainer?: EventTarget };
}).gBrowser ?? null;
const allTabs = (): Element[] => {
const tabs = browser()?.tabs;
return tabs ? Array.from(tabs) : [];
};
/** その id のタブ。閉じたタブの id は、もうどのタブでもない(null が返る) */
const tabOf = (id: string): Element | null => allTabs().find((t) => tabIds.get(t) === id) ?? null;
// 覚書は鍵で一つずつ訊くものなので、setup が「どの鍵を持っているか」を言う。
// 宣言が無ければ、鍵は無いのと同じ(読むほうも書くほうも通らない)。
const wantedValues = setup.tab_values ?? [];
if (wantedValues.length && !policy.tabValues) refuse("タブの覚書を読む", "tab_values");
const valueKeys = policy.tabValues ? wantedValues.filter((k) => NAME_OK.test(k)) : [];
/**
* 復元で戻ってくるタブの覚書を、包みのところで預かる棚。
*
* SessionStore がその覚書をタブに結びつけるより **先に** 包みのほうが見ている
* (包みは戻すタブの data をそのまま持っている)ので、その間に一覧を作ると
* 「覚書を持っていないタブ」に見える ── 引き受けてくれる drop は、それを
* 自分の束に入れてしまう(実機で一度、そうやって別の束のタブを奪った)。
*/
const kept = new WeakMap<Element, Record<string, string>>();
let store: { getCustomTabValue(t: Element, k: string): string } | null = null;
const sessionStore = () =>
(store ??= ChromeUtils.importESModule(
"resource:///modules/sessionstore/SessionStore.sys.mjs",
).SessionStore);
/**
* 一枚のタブが、logic にどう見えるか(abi の tab)。
*
* `url` は current_url も宣言した drop にだけ入る -- `Ask("url")` と同じ線で、
* 「タブのことを読む」と「どこを見ているかを読む」は別の一文だから。
* `values` は、その drop 自身の覚書だけ。
*/
const tabFact = (tab: Element, index = allTabs().indexOf(tab)): Record<string, unknown> => {
const t = tab as Element & {
pinned?: boolean;
linkedBrowser?: { currentURI?: { spec?: string } };
group?: { id?: string } | null;
};
const values: Record<string, unknown> = {};
for (const key of valueKeys) {
const raw = (() => {
try {
const live = sessionStore().getCustomTabValue(tab, VALUE + key);
if (live !== "") return live;
} catch {
// SessionStore がまだそのタブを知らないことがある(復元の最中)
}
return kept.get(tab)?.[VALUE + key] ?? "";
})();
if (raw !== "") values[key] = raw;
}
return {
id: idOf(tab),
index,
title: tab.getAttribute("label") ?? "",
url: policy.currentUrl ? (t.linkedBrowser?.currentURI?.spec ?? "") : "",
pinned: t.pinned === true,
selected: tab === browser()?.selectedTab,
// 仕舞われているタブ。この drop が仕舞ったものも、ほかの誰かが仕舞った
// ものも、同じ一つとして見える(タブ帯に出ていない、という事実だから)
hidden: (tab as { hidden?: boolean }).hidden === true,
muted: tab.hasAttribute("muted"),
// まだ中身を持っていないタブ(前回から戻ってきて、まだ開かれていない)。
// Firefox がそれに着せている属性を、そのまま読んでいる
discarded: tab.hasAttribute("pending"),
group: t.group?.id ?? "",
container: tab.getAttribute("usercontextid") ?? "",
values,
};
};
const tabsFact = (): Record<string, unknown>[] => {
if (!policy.tabs) {
refuse("タブのことを読む", 'tabs = "read"');
return [];
}
return allTabs().map((tab, i) => tabFact(tab, i));
};
const selectedFact = (): Record<string, unknown> | null => {
if (!policy.tabs) {
refuse("タブのことを読む", 'tabs = "read"');
return null;
}
const tab = browser()?.selectedTab;
return tab ? tabFact(tab) : null;
};
/**
* タブを選ぶ・仕舞う・また見せる。`tabs` の上の段。
*
* 読むだけの drop と、並びに手を入れる drop は、入れる人にとって別のことなので、
* 段で分けてある(画面に出る一文も別)。段が足りなければ、しない。
*/
const mayWriteTabs = (what: string): boolean => {
if (policy.tabs === "write") return true;
refuse(what, 'tabs = "write"');
return false;
};
/**
* タブを仕舞う / また見せる。選ばれているタブを仕舞わないのは、本体の判断。
*
* 仕舞ったタブは覚えておく。**drop を外すときに、見せて返す**ため ── タブ帯から
* 消えたタブを戻す道が、外した人の手元に無くなってしまうので。
*/
const folded = new WeakSet<Element>();
const foldTab = (tab: Element, away: boolean): void => {
const gb = browser() as unknown as
| { hideTab?: (t: Element) => void; showTab?: (t: Element) => void }
| null;
const fn = away ? gb?.hideTab : gb?.showTab;
if (!gb || !fn) {
console.warn("[tsubaki-actor] この窓では、タブを仕舞えない");
return;
}
try {
fn.call(gb, tab);
if (away) folded.add(tab);
else folded.delete(tab);
} catch (e) {
console.warn("[tsubaki-actor] タブを仕舞う / 見せるところで転んだ:", e);
}
};
if (policy.tabs === "write") {
// 窓が閉じるときは、返さない。そのときの仕舞われかたは、その窓の姿として
// SessionStore が持っていくものなので、ここで見せてしまうと「閉じた窓を戻す」が
// ぜんぶ見える状態で戻ってくる。外されたのか、窓が閉じたのかを、先に知っておく
let closing = false;
ctx.io.listen(window, "unload", () => {
closing = true;
});
ctx.io.defer(() => {
if (closing) return;
for (const tab of allTabs()) if (folded.has(tab)) foldTab(tab, false);
});
}
/** 目印 / 覚書の、短い名前を、本当の名前に。読めない名前は付けない */
const longName = (short: unknown, prefix: string): string | null => {
const name = String(short ?? "");
if (NAME_OK.test(name)) return prefix + name;
console.warn("[tsubaki-actor] 目印 / 覚書の名前は a-z0-9- で:", name);
return null;
};
/**
* タブに、この drop の目印を一つ。**二つの顔**で置く -- 選ぶための属性と、
* 字を出すための変数。ここが一か所なのは、戻ってくるタブに焼くほう
* (bakeOnRestore)も、同じ手で置く必要があるから。
*/
const markTab = (tab: Element, short: unknown, value: string): void => {
const name = longName(short, MARK);
if (!name) return;
tab.setAttribute(name, value);
// CSS の文字列として置く(`content: var(--…)` にそのまま渡せるように)
(tab as HTMLElement).style.setProperty(MARK_VAR + String(short), JSON.stringify(value));
};
// --- 窓の覚書 -----------------------------------------------------------------
// タブのものと同じ作法で、置き場所が窓なだけ。タブ一枚に属さないこと
// (その窓で、どの束を開いていたか)は、ここに置く。
const wantedWindow = setup.window_values ?? [];
if (wantedWindow.length && !policy.windowValues) refuse("窓の覚書を読む", "window_values");
const windowKeys = policy.windowValues ? wantedWindow.filter((k) => NAME_OK.test(k)) : [];
const windowValue = (key: string): string => {
try {
return (sessionStore() as unknown as { getCustomWindowValue(w: Window, k: string): string })
.getCustomWindowValue(window, VALUE + key);
} catch {
return "";
}
};
/** start(facts) の `window`。書いていない鍵は、入らない */
const windowFacts = (): Record<string, unknown> => {
const out: Record<string, unknown> = {};
for (const key of windowKeys) {
const raw = windowValue(key);
if (raw !== "") out[key] = raw;
}
return out;
};
/**
* 名札のところに出す、字を打つ欄。
*
* 一度の編集のために生まれて、Enter / 外を押す で閉じる。**要素は殻のもの**
* (`.nora-prompt`)で、焦点も caret もここが持つ -- view で描けるものではないし、
* 描けたとしても、タブ自身の並びの中に置くのは drop の仕事ではない。
*
* Escape のときは、何も dispatch しない。打っていた字はどこにも残らないので、
* logic のほうに「やめた」を届けても、することが無い。
*/
let promptSheet = false;
const promptOn = (effect: Action): void => {
const tab = tabOf(String(effect.tab ?? ""));
if (!tab) return;
const label = tab.querySelector(".tab-label") as HTMLElement | null;
const container = tab.querySelector(".tab-label-container");
if (!label || !container) {
console.warn("[tsubaki-actor] Prompt: そのタブに名札が無い");
return;
}
if (!promptSheet) {
promptSheet = true;
// 欄は殻のものなので、見た目も殻が持つ。drop の一枚が要らない
ctx.io.style(
document,
".nora-prompt { margin: 0; min-width: 0; flex: 1; font: inherit; outline: none;" +
" padding: 2px 4px; border-radius: 4px;" +
" background: var(--toolbar-field-background-color, Field);" +
" color: var(--toolbar-field-color, FieldText);" +
" border: 1px solid var(--toolbar-field-border-color, ButtonBorder); }",
);
}
const input = document.createElementNS(XHTML, "input") as HTMLInputElement;
input.type = "text";
input.className = "nora-prompt";
input.value = String(effect.value ?? "");
input.placeholder = String(effect.placeholder ?? "");
const shown = label.style.display;
label.style.display = "none";
ctx.io.place(input, { before: container });
input.focus();
input.select();
let over = false;
const close = () => {
if (over) return;
over = true;
input.remove();
label.style.display = shown;
};
const save = () => {
if (over) return;
const typed = input.value.trim();
close();
dispatch({ __type: String(effect.action), tab: String(effect.tab ?? ""), value: typed });
};
// 打っている途中で drop が外されることもある。そのときは、打った字は
// どこにも残さずに、名札だけ戻す
ctx.io.defer(close);
ctx.io.listen(input, "blur", save);
ctx.io.listen(input, "keydown", (ev: KeyboardEvent) => {
if (ev.key === "Enter") {
ev.preventDefault();
save();
} else if (ev.key === "Escape") {
ev.preventDefault();
close();
}
});
};
const dispatch = (action: Action): void => {
ops.call("dispatch", action).then(
(frame) => take(frame as Frame),
(e) => console.error("[tsubaki-actor] dispatch failed:", action.__type, e),
);
};
// the view goes up BEFORE the effects run: an effect may dispatch again, and
// that inner frame has to be the one that stays
const take = (frame: Frame): void => {
views.value = spread(frame.view, names[0]);
for (const effect of frame.effects ?? []) perform(effect);
};
/** The whole vocabulary of "do this" a Tsubaki actor has. Anything else: write an actor.ts. */
const perform = (effect: Action): void => {
note(
String(effect.__type),
brief(effect.name ?? effect.url ?? effect.selector ?? effect.command ?? effect.fields ?? effect.text ?? ""),
effect.value === undefined ? undefined : { value: brief(effect.value) },
);
switch (effect.__type) {
case "SetPref": {
const name = String(effect.name);
if (!mayTouchPref(name)) return;
if (asJson.has(name)) Services.prefs.setStringPref(name, JSON.stringify(effect.value ?? null));
else writePref(name, effect.value);
return;
}
case "OpenURL": {
if (!policy.openUrl) return refuse("OpenURL", "open_url");
const win = window as unknown as { openWebLinkIn?: (url: string, where: string) => void };
const url = String(effect.url);
if (!/^https?:\/\//.test(url)) return; // 開くのは web の URL だけ
if (win.openWebLinkIn) win.openWebLinkIn(url, "tab");
else window.open(url, "_blank");
return;
}
case "Ask": {
const action: Action = { __type: String(effect.action) };
for (const field of asStrings(effect.fields)) {
// 事実そのものに宣言が要るものがある。断ったときは空を返す
// -- logic は「訊いたのに来なかった」を扱えばよく、落ちなくていい。
if (field === "url" && !policy.currentUrl) {
refuse('Ask("url")', "current_url");
action[field] = "";
continue;
}
// タブのことは、この窓を見ないと分からない(factOf は窓を持っていない)
if (field === "tabs") {
action[field] = tabsFact();
continue;
}
if (field === "tab") {
action[field] = selectedFact();
continue;
}
action[field] = factOf(field);
}
dispatch(action);
return;
}
case "Measure": {
// after preact has drawn the frame we just put up: the redraw was queued
// as a microtask when views.value was set, so this one runs behind it.
// It is also what lets the FIRST frame ask -- the hosts are mounted
// further down, still inside this same turn.
queueMicrotask(() => {
const el = look(hosts, String(effect.selector));
if (!el) {
console.warn("[tsubaki-actor] Measure: 見つからない:", effect.selector);
return;
}
const box = el.getBoundingClientRect();
dispatch({
__type: String(effect.action),
width: Math.round(box.width),
height: Math.round(box.height),
});
});
return;
}
case "OpenPopup": {
const popup = look(hosts, String(effect.selector)) as
| { openPopupAtScreen?: (x: number, y: number, isContext: boolean) => void }
| null;
if (popup?.openPopupAtScreen) popup.openPopupAtScreen(Number(effect.x), Number(effect.y), true);
else console.warn("[tsubaki-actor] OpenPopup: menupopup が見つからない:", effect.selector);
return;
}
case "ReloadFrame": {
if (!policy.webFrame) return refuse("ReloadFrame", "web_frame");
const frame = look(hosts, String(effect.selector)) as { reload?: () => void } | null;
if (frame?.reload) frame.reload();
else console.warn("[tsubaki-actor] ReloadFrame: 見つからない:", effect.selector);
return;
}
case "DoCommand": {
// 二つの門を通る。**宣言に有る**(入れる人の画面に出ている行)ことと、
// **表に有る**(殻が名前を知っている)こと。どちらか片方でも欠けたら、
// しない -- 綴り間違いでブラウザの知らない口が開かないように。
const name = String(effect.name);
if (!(policy.commands ?? []).includes(name)) return refuse(`DoCommand("${name}")`, "commands");
const run = COMMANDS[name];
if (!run) {
console.warn(`[tsubaki-actor] 表に無い命令: ${name}(abi/v1.json の commands)`);
return;
}
try {
run(window);
} catch (e) {
// 版によっては呼び先が無い。view も、同じ frame の他の effect も止めない
console.warn(`[tsubaki-actor] 命令が通らなかった: ${name}`, e);
}
return;
}
case "SetTabAttr":
case "ClearTabAttr": {
if (!policy.tabMarks) return refuse(effect.__type, "tab_marks");
const tab = tabOf(String(effect.tab ?? ""));
const name = longName(effect.name, MARK);
if (!tab || !name) return;
const varName = MARK_VAR + String(effect.name);
const style = (tab as HTMLElement).style;
if (effect.__type === "ClearTabAttr") {
tab.removeAttribute(name);
style.removeProperty(varName);
} else {
markTab(tab, effect.name, String(effect.value ?? ""));
}
return;
}
case "SetTabValue":
case "ClearTabValue": {
if (!policy.tabValues) return refuse(effect.__type, "tab_values");
const tab = tabOf(String(effect.tab ?? ""));
const key = longName(effect.key, VALUE);
if (!tab || !key) return;
const api = sessionStore() as unknown as {
setCustomTabValue(t: Element, k: string, v: string): void;
deleteCustomTabValue(t: Element, k: string): void;
};
try {
if (effect.__type === "ClearTabValue") api.deleteCustomTabValue(tab, key);
else api.setCustomTabValue(tab, key, String(effect.value ?? ""));
} catch (e) {
console.warn("[tsubaki-actor] 覚書が書けなかった:", key, e);
}
return;
}
case "Prompt": {
if (!policy.prompt) return refuse("Prompt", "prompt");
promptOn(effect);
return;
}
case "HideTab":
case "ShowTab": {
if (!mayWriteTabs(effect.__type)) return;
const tab = tabOf(String(effect.tab ?? ""));
if (tab) foldTab(tab, effect.__type === "HideTab");
return;
}
case "SelectTab": {
if (!mayWriteTabs("SelectTab")) return;
const tab = tabOf(String(effect.tab ?? ""));
const gb = browser() as unknown as { selectedTab?: Element } | null;
if (tab && gb) gb.selectedTab = tab;
return;
}
case "SetWindowValue":
case "ClearWindowValue": {
if (!policy.windowValues) return refuse(effect.__type, "window_values");
const key = longName(effect.key, VALUE);
if (!key) return;
const api = sessionStore() as unknown as {
setCustomWindowValue(w: Window, k: string, v: string): void;
deleteCustomWindowValue(w: Window, k: string): void;
};
try {
if (effect.__type === "ClearWindowValue") api.deleteCustomWindowValue(window, key);
else api.setCustomWindowValue(window, key, String(effect.value ?? ""));
} catch (e) {
console.warn("[tsubaki-actor] 窓の覚書が書けなかった:", key, e);
}
return;
}
case "Log":
console.log("[tsubaki-actor]", effect.text);
return;
default:
console.warn("[tsubaki-actor] 知らない effect:", effect.__type);
}
};
/**
* 前回から戻ってくるタブに、覚書のぶんを焼いておく。
*
* 戻ってきてから(`restore` の出来事を聞いて)付け直すのでは、**一瞬ぜんぶ
* 見える**。本体は戻すタブを DocumentFragment に組んで、最後にまとめて挿す
* ので、その包みの中で焼けば、一度も描かれないまま正しい姿で現れる。
*
* 焼くのは二つ。覚書と同じ名前の**目印**(`marks`)と、**仕舞われていたこと**
* (`hide_unless`: その鍵の覚書が、窓の覚書と違うタブは仕舞ったまま戻す)。
*
* **logic には訊かない。** worker の返事は待てないし、待つあいだに塗られて
* しまう。だから「どう焼くか」は setup() のデータとして先に受け取っておく
* ── style と同じ筋で、決めるのは logic、するのは殻。
*
* ここは本体の形(引数の並び)に寄りかかっている。そこが変わったら焼けなくなる
* ので、**黙らずに一行残す**。そのときも失うのはタブではなく「最初の一瞬」
* だけで、あとは logic が start() で整え直す。
*/
const bakeOnRestore = (restore: Restore): void => {
const gb = browser() as unknown as Record<string, unknown> | null;
const original = gb?.createTabsForSessionRestore;
if (!gb || typeof original !== "function") {
console.warn("[tsubaki-actor] restore: この窓では、戻ってくるタブに焼けない");
return;
}
// 宣言した鍵だけ。覚書を持っていない drop は、焼くものも持っていない
const marks = (restore.marks ?? []).filter((k) => valueKeys.includes(k));
const foldBy = restore.hide_unless ?? "";
if (foldBy !== "" && !mayWriteTabs("restore の hide_unless")) return;
if (marks.length === 0 && foldBy === "") return;
const bake = (out: unknown, args: unknown[]): void => {
const tabs = Array.isArray(out) ? out : (out as { tabs?: unknown[] } | null)?.tabs;
if (!Array.isArray(tabs) || tabs.length === 0) return;
// 覚書は、戻すタブの data のほうに乗っている。**何番目の引数か**には
// 寄りかからずに、「タブと同じ数だけ並んでいるもの」で見つける
// (並びは、本体が index で突き合わせている、その同じ並び)
const list = args.find((a) => Array.isArray(a) && a.length === tabs.length) as
| unknown[]
| undefined;
if (!list) {
console.warn("[tsubaki-actor] restore: 戻ってくるタブの覚書が見つからない");
return;
}
const here = foldBy === "" ? "" : windowValue(foldBy);
for (const [i, tab] of tabs.entries()) {
if (!(tab instanceof Element)) continue;
const ext = (list[i] as { extData?: Record<string, string> } | undefined)?.extData;
if (!ext) continue;
kept.set(tab, ext);
for (const key of marks) {
const value = ext[VALUE + key];
if (value) markTab(tab, key, value);
}
if (foldBy === "") continue;
const mine = ext[VALUE + foldBy] ?? "";
if (mine !== "" && mine !== here) foldTab(tab, true);
}
};
const wrapped = function (this: unknown, ...args: unknown[]): unknown {
const out = (original as (...a: unknown[]) => unknown).apply(this, args);
try {
bake(out, args);
} catch (e) {
console.warn("[tsubaki-actor] restore: 焼けなかった(start で整え直す)", e);
}
return out;
};
gb.createTabsForSessionRestore = wrapped;
// 外すときに返す。別の誰かが更にその上から包んでいたら、そのままにしておく
ctx.io.defer(() => {
if (gb.createTabsForSessionRestore === wrapped) gb.createTabsForSessionRestore = original;
});
};
// 包むのは start より先。セッションの復元は、この actor が着いたあとに来る
if (setup.restore) bakeOnRestore(setup.restore);
take(
(await ops.call("start", {
prefs: readAll(),
url: String(document.location?.href ?? ""),
// もう本当だったこと。タブを読める drop にだけ、最初の一覧を添える
...(policy.tabs ? { tabs: tabsFact() } : {}),
// 窓の覚書も、もう本当だったこと(前に開いていたときの続き)
...(windowKeys.length ? { window: windowFacts() } : {}),
})) as Frame,
);
// 生の CSS の一枚は、どこにでも届く -- 自分が置いたものだけ、ではない。
// node につくデータの style(style.ts が印字するほう)は自分のものなので、ここには来ない。
//
// `{attr}` だけは書き換える(abi の mark_attr)。目印の本当の名前は
// `data-nora-<uuid>-` で始まるので、そのままでは drop が自分の uuid を綴る
// ことになる -- 短い名前で書いて、長いほうにするのは殻、を CSS でも通す。
if (setup.style) {
if (policy.chromeStyle) {
ctx.io.style(
document,
setup.style.replaceAll(abi.mark_attr.token, MARK).replaceAll(abi.mark_attr.var_token, MARK_VAR),
);
}
else refuse("setup().style", "chrome_style");
}
for (const name of watched) {
ctx.io.pref(name, () => dispatch({ __type: "PrefChanged", name, value: readOne(name) }));
}
// 見ていてほしい出来事。並べた名前だけ聞いて、一つの action にして渡す
// -- 出来事ごとに door を増やさないのは、logic のほうで `kind` の一か所に
// 書けるようにするため(view は、どの出来事でも同じ一枚を描き直す)。
const watchTabs = setup.watch?.tabs ?? [];
if (watchTabs.length && !policy.tabs) refuse("タブの出来事を見る", 'tabs = "read"');
const strip = browser()?.tabContainer;
if (policy.tabs && strip) {
const told = (kind: string, tab: Element | null) =>
dispatch({
__type: "TabsChanged",
kind,
tab: tab ? tabFact(tab) : null,
tabs: allTabs().map((t, i) => tabFact(t, i)),
});
for (const kind of watchTabs) {
const spec = (abi.tab_events as Record<string, { event?: string }>)[kind];
if (!spec?.event) {
console.warn(`[tsubaki-actor] 知らない出来事: ${kind}(abi/v1.json の tab_events)`);
continue;
}
if (kind !== "dblclick") {
ctx.io.listen(strip, spec.event, (ev: Event) => told(kind, ev.target as Element));
continue;
}
// 二度押しは、タブの上のどこで起きたかを見てから渡す。閉じるつもりで
// 押した人のぶんは渡さない -- その手つきの意味は、もう決めた人が居る。
ctx.io.listen(strip, "dblclick", (ev: MouseEvent) => {
if (ev.button !== 0 || ev.defaultPrevented) return;
if (Services.prefs.getBoolPref("browser.tabs.closeTabByDblclick", false)) return;
const target = ev.target as Element | null;
if (target?.closest(".tab-close-button")) return;
const tab = target?.closest(".tabbrowser-tab");
if (!tab) return;
ev.preventDefault();
told(kind, tab);
});
}
}
// 目印は、この drop がタブに付けたもの。drop を外すときに、全部外す
// (mount の台帳に載らない -- タブは殻が置いたものではないので)。
if (policy.tabMarks) {
ctx.io.defer(() => {
for (const tab of allTabs()) {
for (const attr of Array.from(tab.attributes)) {
if (attr.name.startsWith(MARK)) tab.removeAttribute(attr.name);
}
const style = (tab as HTMLElement).style;
for (const prop of Array.from(style)) {
if (prop.startsWith(MARK_VAR)) style.removeProperty(prop);
}
}
});
}
for (const [i, anchor] of anchors.entries()) {
if (!belongsHere(anchor)) continue;
const at = anchor.at === "settings"
? await settingsPlace(ctx.io, policy.uuid ?? "", anchor.id)
: anchor.at === "toolbar"
? toolbarPlace(policy.uuid ?? "", anchor)
: anchor.at === "menu"
? menuPlace(anchor, policy)
: placeOf(anchor);
if (!at) continue;
const host = mount(ctx.io, h(View, { views, name: names[i], dispatch, policy: viewPolicy, sheet }), at);
hosts.push(host);
if (anchor.at === "menu") {
// 行は popup の直接の子でないと組まれない。host は空のまま残る(std-context-menu)。
// 門(表に有るか / 宣言に有るか)は menuPlace で通してある
const spec = (abi.menus as Record<string, { about?: string }>)[anchor.menu ?? ""];
placeMenuRows(ctx.io, host, {
mark: MARK,
holder: abi.mark_attr.tab_holder,
aboutTab: spec?.about === "tab",
idOf,
});
}
}
}
function View(props: {
views: ReadonlySignal<Record<string, VNode | null>>;
name: string;
dispatch: (a: Action) => void;
policy: ViewPolicy;
sheet: Sheet;
}) {
const v = useSignalValue(props.views)[props.name];
return v ? toPreact(v, props.dispatch, props.policy, props.sheet) : null;
}
/** A frame's view as "which anchor gets what". One VNode goes to the first anchor. */
function spread(view: Frame["view"], first: string): Record<string, VNode | null> {
if (!view) return {};
if (typeof (view as VNode).tag === "string") return { [first]: view as VNode };
return { ...(view as Record<string, VNode | null>) };
}
/**
* ツールバーの、この drop のひとこま。
*
* CustomizableUI の widget を一つ作って、その **この窓のぶん**を返す。widget は
* アプリに一つ、actor は窓ごとなので、どの窓の actor が先に着いても同じことに
* なるように書いてある: まだ無ければ作り、あれば `forWindow` で自分の窓のものを
* 訊く(そこが無ければ CustomizableUI が `onBuild` でその場で作る)。
*
* **どこに置かれているかを、drop は知らない。** 入れた人が customize mode で
* 動かした場所をブラウザが覚えていて、drop が言えるのは「最初はここに」だけ。
* これは減らした機能ではなく、そういう約束 -- 位置は入れた人のもの。
*
* 片づけは **ここではしない**。widget はアプリに一つで、この defer は「窓が
* 閉じた」でも走る。窓を一つ閉じただけで全部の窓からボタンが消えては困るので、
* `destroyWidget` は drop を外す側(Drops.sys.mts)が、uuid から名前を組んで呼ぶ。
* 置いた view のほうは mount の台帳で戻る。
*/
function toolbarPlace(uuid: string, anchor: Anchor): Parameters<typeof mount>[2] | null {
if (uuid === "") {
console.warn('[tsubaki-actor] at: "toolbar": この drop の uuid が分からない');
return null;
}
const { CustomizableUI } = ChromeUtils.importESModule(
"moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs",
) as { CustomizableUI: CustomizableUILike };
const id = widgetId(uuid);
const area = Object.hasOwn(abi.toolbar_areas, anchor.area ?? "") ? anchor.area! : "nav-bar";
if (CustomizableUI.getWidget(id)?.provider !== CustomizableUI.PROVIDER_API) {
CustomizableUI.createWidget({
id,
type: "custom",
defaultArea: area,
removable: true,
onBuild(doc: Document) {
const item = doc.createXULElement("toolbaritem");
item.id = id;
item.setAttribute("removable", "true");
item.classList.add("chromeclass-toolbar-additional");
return item;
},
});
}
const node = CustomizableUI.getWidget(id)?.forWindow(window)?.node ?? null;
if (!node) {
console.warn("[tsubaki-actor] toolbar: この窓のこまが作れなかった:", id);
return null;
}
return { parent: node, tag: "hbox", id: anchor.id };
}
/**
* 本体の menupopup の中の、この drop の行。
*
* 足すのは **末尾**(`appendChild`)。本体の menu には並びの決まりがあって
* (`MenuSectionLayout` は、どの section にも属さない子を途中で見つけると投げる)、
* 空いているのは末尾の一続きだけ。本体の行は動かさないし、消しもしない。
*
* 門は他と同じ二つ -- **表に有る**(abi の menus)ことと、**宣言に有る**こと。
*/
function menuPlace(anchor: Anchor, policy: ViewPolicy): Parameters<typeof mount>[2] | null {
const name = anchor.menu ?? "";
const spec = (abi.menus as Record<string, { id?: string; about?: string }>)[name];
if (!spec?.id) {
console.warn(`[tsubaki-actor] 表に無い menu: ${name}(abi/v1.json の menus)`);
return null;
}
if (!(policy.menus ?? []).includes(name)) {
console.warn(
`[tsubaki-actor] ${name} は宣言されていないので、行を混ぜない` +
"(drop.toml の [permissions] に menu = [...])",
);
return null;
}
const popup = document.getElementById(spec.id);
if (!popup) {
console.warn(`[tsubaki-actor] menu が見つからない: ${spec.id}`);
return null;
}
return { parent: popup, tag: "vbox", id: anchor.id };
}
/** ツールバーの widget の名前。外す側(Drops.sys.mts)も、uuid から同じ名前を組む */
function widgetId(uuid: string): string {
return `nora-widget-${uuid}`;
}
interface CustomizableUILike {
PROVIDER_API: string;
// deno-lint-ignore no-explicit-any
createWidget(properties: Record<string, unknown>): any;
getWidget(id: string):
| { provider: string; forWindow(win: Window): { node: Element | null } | null }
| null;
}
/** Where a host goes. The selector is looked up in this document; "body" by default. */
function placeOf(anchor: Anchor): Parameters<typeof mount>[2] {
if (anchor.at === "keyset") {
const main = document.getElementById("mainKeyset");
if (!main) throw new Error('anchor at "keyset": #mainKeyset が無い(窓ではない document)');
return { after: main, tag: "keyset", id: anchor.id };
}
const selector = anchor.selector ?? "body";
const el = document.querySelector(selector);
if (!el) throw new Error(`anchor not found: ${selector}`);
const tag = anchor.tag ?? (el.namespaceURI?.includes("there.is.only.xul") ? "vbox" : "html:div");
const id = anchor.id;
if (anchor.at === "after") return { after: el, tag, id };
if (anchor.at === "before") return { before: el, tag, id };
return { parent: el, tag, id };
}
/**
* The selector, looked for inside what this drop put in the window -- its own
* hosts and their children, never the rest of the browser. A drop measures what
* it drew.
*/
function look(hosts: Element[], selector: string): Element | null {
for (const host of hosts) {
if (host.matches(selector)) return host;
const found = host.querySelector(selector);
if (found) return found;
}
return null;
}
/** A fact only this side can know, by the name the logic asked for. */
function factOf(field: string): unknown {
switch (field) {
case "uuid":
return crypto.randomUUID();
case "url": {
// the tab you are looking at when this is a browser window; otherwise this
// document. "" when it is not a web page -- an about: or a file: URL is
// not something a drop should be handed by asking
const win = window as unknown as { gBrowser?: { currentURI?: { spec?: string } } };
const url = win.gBrowser?.currentURI?.spec ?? String(document.location?.href ?? "");
return /^https?:\/\//.test(url) ? url : "";
}
default:
console.warn("[tsubaki-actor] Ask: 知らない事実:", field);
return null;
}
}
function asStrings(value: unknown): string[] {
if (typeof value === "string") return [value];
return Array.isArray(value) ? value.map(String) : [];
}
/** The pref as it is: its type in about:config decides. Absent is `nothing` on the other side. */
function readPref(name: string): boolean | number | string | null {
switch (Services.prefs.getPrefType(name)) {
case Services.prefs.PREF_BOOL:
return Services.prefs.getBoolPref(name, false);
case Services.prefs.PREF_INT:
return Services.prefs.getIntPref(name, 0);
case Services.prefs.PREF_STRING:
return Services.prefs.getStringPref(name, "");
default:
return null;
}
}
/** A pref whose string is JSON. `nothing` when it is unset, or unreadable. */
function readJsonPref(name: string): unknown {
const text = Services.prefs.getStringPref(name, "");
if (text === "") return null;
try {
return JSON.parse(text);
} catch {
console.warn("[tsubaki-actor] JSON として読めない pref:", name);
return null;
}
}
function writePref(name: string, value: unknown): void {
if (typeof value === "boolean") Services.prefs.setBoolPref(name, value);
else if (typeof value === "number") Services.prefs.setIntPref(name, Math.round(value));
else if (typeof value === "string") Services.prefs.setStringPref(name, value);
else console.warn("[tsubaki-actor] SetPref: bool / int / string のどれかで:", name, value);
}
lib/vnode.ts · 426 lines
// SPDX-License-Identifier: MPL-2.0
// A view that came from the logic as data, turned into elements.
//
// The logic (ops/*.tsubaki) answers with a tree of `VNode(tag, props, kids)` and
// never touches the DOM: it cannot, it runs in a worker. This is the translator,
// and it decides nothing. An `"on:*"` prop is not a closure -- closures do not
// cross a postMessage -- it is the *action to raise*, handed back to dispatch as
// it is, with the few facts only this side can know (where the click was, what
// the field says) alongside it.
//
// It does hold one line, though: WHICH TAGS a view may name. The promise a drop
// makes to whoever installs it is "the effects are the whole list of what this
// can do", and that promise is only true if the elements are inert -- boxes,
// labels, buttons, menu rows. An element that loads a page is not inert, so it
// is not in ELEMENTS; a drop that wants one says so in drop.toml (`web_frame`)
// and the shell dresses it here, rather than the drop's own words deciding what
// kind of window it gets.
import { Fragment, h, type ComponentChild } from "std-preact-xul";
import { isStyleData, printStyle, type Sheet, type StyleData } from "./style.ts";
// 殻が drop に許していることの、ただ一つの表。repo では ../abi.json が
// abi/v1.json への symlink、build のときは build.rb が同じ名前で写すので、
// 書く場所は一つのまま、どちらからでも同じものを読む。
import abi from "../abi.json" with { type: "json" };
const TAGS: ReadonlySet<string> = new Set(abi.tags);
const WEB_FRAME_TAG: string = abi.web_frame_tag;
const PROPS: ReadonlySet<string> = new Set(abi.props.any);
const PROP_PREFIXES: readonly string[] = abi.props.prefixes;
const URL_PROPS: ReadonlySet<string> = new Set(abi.props.url_valued);
const URL_SCHEMES: readonly string[] = abi.props.url_schemes;
// Firefox 自身のアイコン(chrome://global/skin/)。任意の chrome: ではなく、ここだけ
const URL_PREFIXES: readonly string[] = abi.props.url_prefixes;
// <key> -- 押されたことを聞ける、ただ一つの「描かない要素」。宣言した組み合わせ
// だけで、綴り直すのは殻(abi/v1.json の key_combo)。
const KEY_TAG: string = abi.key_tag;
const KEY_MODIFIERS: Record<string, string> = abi.key_combo.modifiers;
const KEY_NAMED: Record<string, string> = abi.key_combo.named;
// `<browser>` に `page => "bookmarks"` と書いたときの、読み込む先。名指しだけ。
const BROWSER_PAGES: Record<string, { ja: string; url: string }> = abi.browser_pages;
// popup が開いたとき、殻が host に置いていく「この menu は、このタブについて」
const TAB_HOLDER: string = abi.mark_attr.tab_holder;
export interface VNode {
tag: string;
props: Record<string, unknown>;
kids: (VNode | string)[];
}
export interface Action {
__type: string;
[field: string]: unknown;
}
/**
* What this drop declared in drop.toml's `[permissions]`. The shell holds it and
* turns down anything not in it -- so "これ以外のことはできません" on the install
* screen is a sentence about this object, not a hope. abi/v1.json is the table
* both this and that screen are written from.
*/
export interface ViewPolicy {
/** `web_frame = true`: the view may say `browser` (a window that loads a web page) */
webFrame?: boolean;
/** `chrome_style = true`: `setup().style` (one raw sheet, which reaches anything) */
chromeStyle?: boolean;
/** `open_url = true`: OpenURL */
openUrl?: boolean;
/** `current_url = true`: Ask("url") */
currentUrl?: boolean;
/** `prefs = [...]`: the prefs it may read and write, by name */
prefs?: string[];
/** `keys = [...]`: the key combinations it may take, written as they are pressed ("Accel+Alt+Z") */
keys?: string[];
/** `commands = [...]`: the browser's own commands it may run, by name (abi の commands) */
commands?: string[];
/** `browser_pages = [...]`: the browser's own pages its frame may load, by name */
browserPages?: string[];
/** `tabs = "read"`: it may be told about the window's tabs (abi の tab) */
tabs?: string;
/** `tab_marks = true`: SetTabAttr -- its own mark on a tab, for its own CSS to find */
tabMarks?: boolean;
/** `tab_values = true`: SetTabValue -- its own memo on a tab, kept across restarts */
tabValues?: boolean;
/** `window_values = true`: SetWindowValue -- the same, on the window */
windowValues?: boolean;
/** `prompt = true`: Prompt -- a little field to type in, which takes the focus */
prompt?: boolean;
/** `menu = [...]`: the browser's own menupopups it may add rows to, by name */
menus?: string[];
/** its own corner of about:config (noraneko.<name>.), free without listing */
ownPrefix?: string;
/** この drop の名前と正体。したことの記録("nora-drop-did")に添える */
name?: string;
uuid?: string;
/**
* この drop の正体(drop.toml の uuid)。宣言ではないけれど、殻がこれを持って
* いないと、設定の頁でこの drop 自身の一枚を見つけられない。
*/
uuid?: string;
/**
* 鍵 → 字。**選び終わったもの**(どのロケールにするかは tsubakiActor が決める)。
* logic は `t(:add)` と鍵で書き、どの字になるかを知らない -- style と同じ筋で、
* データを渡して、印字するのは殻。
*/
text?: Record<string, string>;
}
/** What only this side can know about the event that raised an action. */
export interface EventFacts {
screenX?: number;
screenY?: number;
value?: string;
checked?: boolean;
key?: string;
/** the page's own title, when the thing that raised this is a web frame */
title?: string;
/** the tab this was about, when it was raised from a menu that is about a tab */
tab?: string;
}
/**
* The tags a view may name. The namespace is the host's, so one name covers
* both spellings where they agree (`label`, `button` are XUL under a XUL host
* and HTML under an HTML one) -- pick the host's tag in `setup`'s anchor.
*
* Everything here is inert: it draws, it holds text, it can be clicked. Nothing
* here loads anything, runs anything, or reaches outside the window. Growing
* this list is a PR to the registry, read by the same people who read the drops.
*/
const ELEMENTS = TAGS;
/** The XUL tag for a window that loads a web page. Only with `web_frame`. */
const WEB_FRAME = WEB_FRAME_TAG;
/**
* What makes a <browser> a *content* window rather than a chrome one, and what
* hands its tooltips and context menu to the browser around it. Set here, not
* by the drop: nine attributes are nine chances to forget one, and forgetting
* this particular one is not the kind of mistake a person should be able to
* make by writing a view. The drop says where it goes and what to load; the
* shell says what kind of window it is.
*/
const WEB_FRAME_ATTRS: Record<string, string> = {
type: "content",
remote: "true",
maychangeremoteness: "true",
messagemanagergroup: "browsers",
disableglobalhistory: "true",
tooltip: "aHTMLTooltip",
autocompletepopup: "PopupAutoComplete",
contextmenu: "contentAreaContextMenu",
};
/**
* ブラウザ自身のページを読む窓。`remote` を **付けない**のがここの全部で、
* 残りは Firefox の `#sidebar` が着ているものと同じ。
*/
const PAGE_FRAME_ATTRS: Record<string, string> = {
disablehistory: "true",
disablefullscreen: "true",
disableglobalhistory: "true",
autoscroll: "false",
tooltip: "aHTMLTooltip",
messagemanagergroup: "browsers",
autocompletepopup: "PopupAutoComplete",
};
export function toPreact(
node: VNode | string,
dispatch: (a: Action) => void,
policy: ViewPolicy = {},
sheet?: Sheet,
): ComponentChild {
if (typeof node === "string") return node;
if (isText(node)) return say(node, policy);
const tag = allow(node.tag, policy);
// <key> は押しかたを combo の一つの字で持っていて、keycode / key / modifiers に
// 綴り直すのは殻。宣言に無い組み合わせは **その <key> だけ** 置かない -- 一枚が
// 丸ごと消えると、直す人には何が起きたのか見えないので。
const keyAttrs = tag === KEY_TAG ? bindKey(String(node.props.combo ?? ""), policy) : undefined;
if (tag === KEY_TAG && !keyAttrs) return null;
const props: Record<string, unknown> = {};
let styleClass: string | undefined;
for (const key of Object.keys(node.props)) {
const value = node.props[key];
if (value === null || value === undefined) continue;
if (keyAttrs && key === "combo") continue; // 殻が綴り直したので、もう用は済んでいる
if (tag === WEB_FRAME && key === "page") {
props.page = value; // 下で URL にする(属性としては出ていかない)
continue;
}
if (key.startsWith("on:")) {
// a copy this side owns: what comes out of the worker is only read through
const action = { ...(value as Action) };
const type = key.slice(3);
props[`on${type}`] = (ev: Event) => {
// a view that says what a right-click means, means it: the browser's own
// menu opening on top of the drop's would be nobody's intention
if (type === "contextmenu") ev.preventDefault();
dispatch({ ...action, __event: factsOf(ev) });
};
} else if (key === "style" && isStyleData(value)) {
// style はデータで来る。CSS の字にするのは style.ts だけで、そこが
// 印字できないものは、ここにも出てこない(url( も `;` も書きようがない)。
// 入れ子があれば class 一つと規則一枚、無ければ style 属性。
const printed = printStyle(value as StyleData, sheet);
if (printed.style) props.style = printed.style;
styleClass = printed.class;
} else if (isText(value)) {
if (allowProp(tag, key, "")) props[key] = say(value, policy);
} else if (allowProp(tag, key, value)) {
props[key] = value;
}
}
// view が class も書いていたら、両方を着せる
if (styleClass) props.class = [props.class, styleClass].filter(Boolean).join(" ");
// `<browser page="bookmarks">`: 名前を URL にするのは殻。drop は chrome: の綴りを
// 一度も書かないし、表と宣言の両方に有る名前しか通らない
if (tag === WEB_FRAME && props.page !== undefined) {
const named = page(String(props.page), policy);
delete props.page;
if (!named) return null;
props.src = named;
}
if (keyAttrs) {
const { key: char, ...rest } = keyAttrs;
Object.assign(props, rest);
// `key` だけは props で渡せない -- preact がそれを「並べ替えの目印」として
// 自分のものにしていて、属性には降りていかない(diffProps が名指しで飛ばす)。
// XUL の <key key="Z"> では、そこが押す字そのものなので、要素ができたところで書く。
if (char) props.ref = (el: Element | null) => el?.setAttribute("key", char);
}
if (tag === WEB_FRAME) dress(props);
return h(
tag === "fragment" ? Fragment : tag,
props,
node.kids.map((kid) => toPreact(kid, dispatch, policy, sheet)),
);
}
function allow(tag: string, policy: ViewPolicy): string {
if (tag === "fragment" || ELEMENTS.has(tag)) return tag;
if (tag === KEY_TAG) {
if (policy.keys?.length) return tag;
throw new Error(
`view: <${KEY_TAG}> は drop.toml の [permissions] に keys = [...] と書いた drop だけ` +
"(入れる人の画面に「キーボードの … を受け取ります」と出る)",
);
}
if (tag === WEB_FRAME) {
if (policy.webFrame) return tag;
throw new Error(
`view: <${WEB_FRAME}> は drop.toml の [actor] に web_frame = true と書いた drop だけ` +
"(入れる人の画面に「ページを読み込む窓を置く」と出る)",
);
}
throw new Error(
`view: 知らない要素 <${tag}>。殻が描けるのは決まった顔ぶれだけ` +
"(_shared/vnode.ts の ELEMENTS)。要るなら registry に PR を、" +
"それが要素ひとつで済む話でないなら actor.ts を書く道がある",
);
}
/**
* 書いてよい属性(abi/v1.json の props)。要素は inert でも、属性は inert ではない --
* `dangerouslySetInnerHTML` は木を丸ごと差し込むし、`src` は電話をかける。だから
* 表に無い名前は落として、console に一行。`style` はデータ(style.ts)、`on:*` は
* 行事で、どちらもここまで来ない。
*/
/** `t(:add)` が越えてきた形。並べていない鍵は、鍵そのものを出す(黙って消さない) */
function isText(v: unknown): v is { __type: "T"; key: string } {
return typeof v === "object" && v !== null && (v as { __type?: string }).__type === "T";
}
function say(v: { key: string }, policy: ViewPolicy): string {
const text = policy.text?.[v.key];
if (text === undefined) console.warn(`[view] 字が並んでいない鍵: ${v.key}(strings.toml)`);
return text ?? v.key;
}
export function allowProp(tag: string, key: string, value: unknown): boolean {
if (!PROPS.has(key) && !PROP_PREFIXES.some((p) => key.startsWith(p))) {
console.warn(`[view] <${tag}> の ${key} は書けない(abi/v1.json の props)`);
return false;
}
if (URL_PROPS.has(key)) {
const url = String(value);
const ok = URL_SCHEMES.some((scheme) => url.startsWith(scheme)) ||
URL_PREFIXES.some((prefix) => url.startsWith(prefix));
if (!ok) {
console.warn(
`[view] <${tag}> の ${key}: ${[...URL_SCHEMES, ...URL_PREFIXES].join(" / ")} で始まる URL だけ`,
);
return false;
}
}
return true;
}
/**
* 一つの <key> の、押しかた。
*
* view が書くのは `combo => "Accel+Alt+Z"` の一つの字で、XUL が待っている三つ
* (`key` / `keycode` / `modifiers`)に綴り直すのはここ。同じ字が drop.toml の
* 宣言にも並んでいるので、**入れる人が読んだ行と、実際に押せる鍵が同じもの**に
* なる -- 三つに散らばっていたら、その約束は目で確かめられない。
*
* Accel は、どこでも同じ指(Windows と Linux は Ctrl、macOS は Cmd)。
*
* 置かないときは null。宣言に無い / 読めない、どちらも **その <key> 一つだけ**で、
* 他の鍵と view の残りはそのまま。
*/
function bindKey(combo: string, policy: ViewPolicy): Record<string, string> | null {
if (combo === "") {
console.warn(`[view] <${KEY_TAG}> に combo が無い("Accel+Alt+Z" のように書く)`);
return null;
}
const declared = (policy.keys ?? []).map(sameKey);
if (!declared.includes(sameKey(combo))) {
console.warn(
`[view] ${combo} は宣言に無いので、この <${KEY_TAG}> だけ置かない` +
"(drop.toml の [permissions] に keys = [...])",
);
return null;
}
const spelled = spellKey(combo);
if (!spelled) console.warn(`[view] <${KEY_TAG}> の combo が読めない: ${combo}`);
return spelled;
}
/** 同じ押しかたなら同じ字に。指の並び順と、大文字小文字は、押しかたを変えない */
function sameKey(combo: string): string {
const parts = combo.split("+").map((p) => p.trim().toLowerCase()).filter(Boolean);
const last = parts.pop() ?? "";
return [...parts.sort(), last].join("+");
}
/** "Accel+Alt+Z" -> { modifiers: "accel alt", key: "Z" } */
function spellKey(combo: string): Record<string, string> | null {
const parts = combo.split("+").map((p) => p.trim()).filter(Boolean);
const last = parts.pop() ?? "";
const modifiers: string[] = [];
for (const part of parts) {
const m = KEY_MODIFIERS[part.toLowerCase()];
if (!m) return null;
if (!modifiers.includes(m)) modifiers.push(m);
}
const out: Record<string, string> = {};
const named = KEY_NAMED[last.toLowerCase()];
if (named) out.keycode = named;
else if (/^[A-Za-z0-9]$/.test(last)) out.key = last.toUpperCase();
else return null;
if (modifiers.length) out.modifiers = modifiers.join(" ");
return out;
}
/**
* ブラウザ自身のページの名前を、読み込む先に。
*
* 門は二つ、命令のときと同じ ── **表に有る**ことと **宣言に有る**こと。
* だから drop は `chrome://` の綴りを一度も書かず、書けるのは名前だけになる
* (任意の chrome: を開く口にはならない)。
*
* 読み込めないときは null で、その `<browser>` だけ置かない。
*/
function page(name: string, policy: ViewPolicy): string | null {
const known = BROWSER_PAGES[name];
// 表には note の一行も混ざっているので、url を持っているものだけ
if (!known?.url) {
console.warn(`[view] 表に無いページ: ${name}(abi/v1.json の browser_pages)`);
return null;
}
if (!(policy.browserPages ?? []).includes(name)) {
console.warn(
`[view] ${name} は宣言に無いので、この窓は置かない` +
"(drop.toml の [permissions] に browser_pages = [...])",
);
return null;
}
return known.url;
}
/**
* The kind-of-window attributes win over anything the view said.
*
* 二通りある。web を読む窓は content の、別のプロセスで走る窓(remote)。
* **ブラウザ自身のページは、そうできない** ── chrome の文書は remote な窓では
* 開かないので、その場(親)で読む窓にする。Firefox 自身のサイドバー(`#sidebar`)も、
* Floorp の静的パネルも、同じ分けかたをしている。
*/
function dress(props: Record<string, unknown>): void {
const src = props.src === undefined ? "" : String(props.src);
const web = /^https?:\/\//.test(src);
for (const [key, value] of Object.entries(web ? WEB_FRAME_ATTRS : PAGE_FRAME_ATTRS)) {
props[key] = value;
}
// the same rule OpenURL keeps: a drop is handed the web, or one of the
// browser's pages it named. An empty src leaves the window blank.
if (!web && !Object.hasOwn(BROWSER_PAGE_URLS, src)) delete props.src;
}
/** 表に有る URL(dress が「これは名前から来た」と言えるように) */
const BROWSER_PAGE_URLS: Record<string, true> = Object.fromEntries(
Object.values(BROWSER_PAGES).filter((p) => p?.url).map((p) => [p.url, true as const]),
);
function factsOf(ev: Event): EventFacts {
const facts: EventFacts = {};
const m = ev as MouseEvent;
if (typeof m.screenX === "number") {
facts.screenX = Math.round(m.screenX);
facts.screenY = Math.round(m.screenY);
}
const t = ev.target as { value?: unknown; checked?: unknown } | null;
if (t && typeof t.value === "string") facts.value = t.value;
if (t && typeof t.checked === "boolean") facts.checked = t.checked;
const k = ev as KeyboardEvent;
if (typeof k.key === "string") facts.key = k.key;
const frame = ev.target as { contentTitle?: unknown } | null;
if (frame && typeof frame.contentTitle === "string") facts.title = frame.contentTitle;
// menu の行は、何かについての行(いまはタブ)。何についてかは、popup が開いた
// ときに殻が host に置いている -- 行そのものは、それを知らないまま書ける。
const about = (ev.target as Element | null)?.closest?.(`[${TAB_HOLDER}]`);
if (about) facts.tab = about.getAttribute(TAB_HOLDER) ?? "";
return facts;
}
What a drop is, how it reaches you, and what it is not: f3liz.casa/noraneko/drops/. Served by https://dl.f3liz.casa/drop, signed by https://github.com/f3liz-casa/noraneko-registry/.github/workflows/verify-and-sign.yml@refs/heads/main.
This page is built when you ask for it, out of what https://dl.f3liz.casa/drop is serving right now (2026-09-23T08:38:28.032Z). Drafted by Shiro, an AI assistant, sitting next to nyanrus.