noraneko · drops

dl.f3liz.casa/drop を読んでいます

rename-tab

タブに自分の名前をつける(ダブルクリック、F2、または右クリック → 名前をつける)。名前はタブについて回るので、閉じて開き直しても戻ってくる

uuid ef4fa247-aeec-497c-8166-134af654cde9

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.

install in noraneko In another browser this is a plain link to the xpi. Stock Firefox will refuse it as unverified; paste the uuid into about:nora:settings in noraneko instead.

who

gh/nyanrus

what is served

rename-tab · rename-tab@noraneko.app · version 2.2.0 · 30940 bytes sha256 6850c0db0d51fc3c93665f6f086851bde89e4f36f1075dda3233216eb66f7ae9 built from drops/rename-tab/src @ 59b96c854c (2026-09-14T07:24:00Z) served at https://dl.f3liz.casa/drop/ef4fa247-aeec-497c-8166-134af654cde9/

in the registry

drops/rename-tab/ @ 59b96c854c — この版が出てきた木 actors: rename-tab

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. 5 files; press one to open it.

_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;
rename-tab/actor.ts · 29 lines
// SPDX-License-Identifier: MPL-2.0
// GENERATED by build.ts. この drop の actor は Tsubaki で書かれている:
// 直すのは ops/Style.tsubaki / ops/rename-tab.tsubaki と drop.toml の [actor]。
//
// 殻は lib の std-actor。logic が答える三つの door(setup / start / dispatch)
// を呼んで、返ってきた view を描き、effects を carry out する。殻の bytes は
// その lib に一枚だけあって、ここには入らない(この drop に残るのは、下の宣言だけ)。

import { defineContent, defineParent, type ActorMeta } from "../_shared/defineActor.ts";
import { runTsubakiActor } from "std-actor";

export const meta: ActorMeta = {
  "id": "rename-tab@noraneko.app",
  "version": "2.2.0",
  "namespace": "noraRenameTab",
  "matches": [
    "chrome://browser/content/browser.xhtml"
  ],
  "runAt": "document_end"
};

export const parent = defineParent({});

export const content = defineContent<typeof parent>((_parent, ctx) => {
  runTsubakiActor(ctx, {"prefs":[],"keys":["F2"],"commands":[],"browserPages":[],"menus":["tabContextMenu"],"tabs":"read","tabMarks":true,"tabValues":true,"windowValues":false,"prompt":true,"currentUrl":false,"openUrl":false,"webFrame":false,"chromeStyle":true,"ownPrefix":"noraneko.rename-tab.","name":"rename-tab","uuid":"ef4fa247-aeec-497c-8166-134af654cde9","strings":{"en":{"rename":"Rename tab…","clear":"Clear name"},"ja":{"rename":"名前をつける…","clear":"名前を消す"}}}).catch((e) =>
    console.error("[rename-tab] failed:", e)
  );
});
rename-tab/ops/Style.tsubaki · 33 lines
# SPDX-License-Identifier: MPL-2.0
#
# rename-tab の一枚。ここは **生の CSS**(`setup().style`)で、drop.toml の
# chrome_style がその宣言 -- タブそのものは、この drop が置いたものではないので。
#
# `{attr}` は、殻がこの drop の目印の接頭辞(`data-nora-<uuid>-`)に書き換える。
# だから drop は自分の uuid を一度も綴らないし、他の drop の目印とも、Firefox
# 自身の属性とも、ぶつかりようがない。
#
# **選ぶのは属性、字を出すのは変数**(`{var}`)。`attr()` は、その擬似要素が乗って
# いる要素の属性しか読めない ── タブに付けた目印は `.tab-label::before` からは
# 見えないので、継ぐほうで渡す。実機で一度、名前が空のまま出た。
#
# タブ自身の label は **消さない**。`font-size: 0` で、並びの中にも、読み上げの
# 木の中にも居たまま、見えるのは付けた名前のほうになる。目印が外れれば、二つとも
# 一緒に戻る。
#
# 最後の一行が、右クリックの「名前を消す」。popup が開くとき、殻が押された
# タブの目印を **その行そのもの**に写してくれるので、**名前のあるタブのときだけ**
# 行が出る。worker を一往復もしない -- popupshowing は待てないので、ここで
# 閉じている必要がある。

module Style
    sheet() = (
        ".tabbrowser-tab[{attr}name] .tab-label::before {" *
        "  content: var({var}name); font-size: 1rem; }" *

        ".tabbrowser-tab[{attr}name] .tab-label { font-size: 0; }" *

        ".nora-rename-clear:not([{attr}name]) { display: none; }"
    )
end
rename-tab/ops/rename-tab.tsubaki · 208 lines
# SPDX-License-Identifier: MPL-2.0
#
# rename-tab: タブに、自分の名前をつける。
#
# 入口は三つ -- F2、タブの二度押し、右クリック → 名前をつける。打ち終わると
# 名前はタブに残るので、閉じて開き直しても、別の窓に引っ張っても、付いてくる。
# 空のまま打ち終われば、タブは自分の題に戻る(「名前を消す」も同じことをする)。
#
# 1.0.1 では、この drop は TypeScript 426 行だった。数えかたを変えたのではなく、
# **そこにあったものが殻の語彙に上がった**:
#
#   io/tabs.ts   タブに目印を付ける          → SetTabAttr / ClearTabAttr
#   io/store.ts  SessionStore に覚書を置く   → SetTabValue / ClearTabValue
#   io/menu.ts   menu に行を混ぜる、<key>    → at: "menu" / at: "keyset"
#   io/input.ts  名札のところに入力欄        → Prompt
#   actor.ts     タブの出来事を聞く          → watch = Watch(tabs = [...])
#
# 残ったのは、それをどう繋ぐか。logic は DOM も SessionStore も知らないし、
# タブは `id`(殻がこの窓のために鋳った不透明な字)としてしか見えない。

KEY = "name"

import Style

# --- setup --------------------------------------------------------------------
# 置き場所は二つ。鍵は窓の keyset の隣、行はタブの右クリックの末尾。
# 見ていてほしいのは五つ -- 開いた / 戻ってきた(覚書を持っているかもしれない)、
# 閉じた / 選ばれた(どれが「いまのタブ」か)、二度押し(入口の一つ)。

setup() = Setup(
    anchors = [
        Anchor(name = "keys", at = "keyset", id = "nora-rename-tab-keys"),
        Anchor(name = "menu", at = "menu", menu = "tabContextMenu", id = "nora-rename-tab-menu")
    ],
    style = Style.sheet(),
    watch = Watch(tabs = ["open", "restore", "close", "select", "dblclick"]),
    tab_values = [KEY]
)

# 覚えているのは、殻が最後に渡してきた一覧だけ。どれが選ばれているかも、
# 名前が何かも、その一覧の中に書いてある -- 二か所で覚えない
@kwdef struct AppState
    tabs = []
end

STATE = AppState()

# --- タブの読みかた -------------------------------------------------------------

id_of(tab) = get(tab, :id, "")
title_of(tab) = get(tab, :title, "")
name_of(tab) = get(get(tab, :values, Dict()), KEY, "")

function find_tab(tabs, id)
    for tab in tabs
        if id_of(tab) == id
            return tab
        end
    end
    return Dict()
end

function selected_id(tabs)
    for tab in tabs
        if get(tab, :selected, false) === true
            return id_of(tab)
        end
    end
    return ""
end

# --- view ---------------------------------------------------------------------
# view は state を見ていない。名前があるかどうかで出し入れする行も、CSS の側で
# 閉じている(Style.tsubaki の最後の一行)-- popup が開くのを待てないので。

view(s) = Dict(
    "keys" => el("key", Dict("combo" => "F2", "on:command" => Rename(""))),
    "menu" => el("fragment", Dict(), [
        el("menuitem", Dict(
            "id" => "nora-rename-tab-row",
            "label" => t(:rename),
            "acceltext" => "F2",
            "on:command" => Rename("")
        )),
        el("menuitem", Dict(
            "id" => "nora-rename-tab-clear",
            "class" => "nora-rename-clear",
            "label" => t(:clear),
            "on:command" => Named("", "")
        ))
    ])
)

# --- actions ------------------------------------------------------------------

abstract type Action end

"名前をつけにいく。tab が空なら、いま選ばれているタブ"
struct Rename <: Action
    tab
end

"打ち終わった(あるいは「名前を消す」が押された)。空の字は、題に戻す"
struct Named <: Action
    tab
    value
end

"タブのほうで何かが起きた"
struct TabsChanged <: Action
    kind
    tab
    tabs
end

# どのタブのことか。menu の行から来たものは __event に(殻が popup の開くときに
# 置いている)、Prompt の返事は action そのものに入っている。どちらも無ければ、
# いま選ばれているタブのこと(F2 はそれ)。
function tab_of(a)
    from_event = get(get(a, :__event, Dict()), :tab, "")
    if from_event != ""
        return from_event
    end
    return get(a, :tab, "")
end

action_of(a) = begin
    kind = get(a, :__type, "")
    if kind == "Rename"
        Rename(tab_of(a))
    elseif kind == "Named"
        Named(tab_of(a), get(a, :value, ""))
    elseif kind == "TabsChanged"
        TabsChanged(get(a, :kind, ""), get(a, :tab, Dict()), get(a, :tabs, []))
    else
        error("rename-tab: no action named " * kind)
    end
end

# --- update -------------------------------------------------------------------

struct Step
    state
    effects
end

"打ち終わったぶんを、手元の一覧にも書いておく(次に開く欄が、それを見せる)"
function remember(s, id, name)
    values = name == "" ? Dict() : Dict(KEY => name)
    tabs = [id_of(tab) == id ? put(tab, "values", values) : tab for tab in s.tabs]
    return AppState(tabs = tabs)
end

"そのタブの名札のところに、いまの名前と、題を置いた欄を出す"
ask_on(s, id) = begin
    tab = find_tab(s.tabs, id)
    Step(s, [Prompt(id, "Named", name_of(tab), title_of(tab))])
end

update(s::AppState, a::Rename) = begin
    id = a.tab == "" ? selected_id(s.tabs) : a.tab
    id == "" ? Step(s, []) : ask_on(s, id)
end

# 空の字は「名前を消す」と同じ -- 覚書も目印も外れて、タブは自分の題に戻る
update(s::AppState, a::Named) = begin
    id = a.tab == "" ? selected_id(s.tabs) : a.tab
    if id == ""
        Step(s, [])
    elseif a.value == ""
        Step(remember(s, id, ""), [ClearTabValue(id, KEY), ClearTabAttr(id, KEY)])
    else
        Step(remember(s, id, a.value), [SetTabValue(id, KEY, a.value), SetTabAttr(id, KEY, a.value)])
    end
end

# 開いた / 戻ってきたタブは、覚書を持っていることがある(前の起動でつけた名前)。
# そのときだけ、目印を付け直す -- 見えるほうは、そうやって覚書に追いつく。
update(s::AppState, a::TabsChanged) = begin
    st = AppState(tabs = a.tabs)
    if a.kind == "dblclick"
        ask_on(st, id_of(a.tab))
    elseif a.kind == "open" || a.kind == "restore"
        name = name_of(a.tab)
        name == "" ? Step(st, []) : Step(st, [SetTabAttr(id_of(a.tab), KEY, name)])
    else
        Step(st, [])
    end
end

# --- the doors ----------------------------------------------------------------

# 最初の一枚。もう開いているタブのうち、名前を持っているものに目印を付ける
# (前の起動から戻ってきたタブは、覚書だけを持って立ち上がってくる)
function start(facts)
    tabs = get(facts, :tabs, [])
    STATE = AppState(tabs = tabs)
    named = filter(tab -> name_of(tab) != "", tabs)
    marks = [SetTabAttr(id_of(tab), KEY, name_of(tab)) for tab in named]
    return frame(view(STATE), marks)
end

function dispatch(a)
    st = update(STATE, action_of(a))
    STATE = st.state
    return frame(view(STATE), st.effects)
end
rename-tab/strings.toml · 8 lines
[en]
rename = "Rename tab…"
clear = "Clear name"

[ja]
rename = "名前をつける…"
clear = "名前を消す"

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.