noraneko · drops

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

shortcut-keys

九つの鍵に、ブラウザの命令を一つずつ(about:nora:settings で選ぶ)

uuid 004bc907-b750-4f9b-aa9c-c88036fff3e9

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

shortcut-keys · shortcut-keys@noraneko.app · version 1.2.0 · 31665 bytes sha256 fdd6a5515a018227d4ac5f8034d757a47d71a72d46e305676e98dac152c930f9 built from drops/shortcut-keys/src @ 59b96c854c (2026-09-14T07:24:00Z) served at https://dl.f3liz.casa/drop/004bc907-b750-4f9b-aa9c-c88036fff3e9/

in the registry

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

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;
shortcut-keys/actor.ts · 30 lines
// SPDX-License-Identifier: MPL-2.0
// GENERATED by build.ts. この drop の actor は Tsubaki で書かれている:
// 直すのは ops/Style.tsubaki / ops/shortcut-keys.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": "shortcut-keys@noraneko.app",
  "version": "1.2.0",
  "namespace": "noraShortcutKeys",
  "matches": [
    "chrome://browser/content/browser.xhtml",
    "chrome://noraneko-settings/*"
  ],
  "runAt": "document_end"
};

export const parent = defineParent({});

export const content = defineContent<typeof parent>((_parent, ctx) => {
  runTsubakiActor(ctx, {"prefs":[],"keys":["Accel+Alt+1","Accel+Alt+2","Accel+Alt+3","Accel+Alt+4","Accel+Alt+5","Accel+Alt+6","Accel+Alt+7","Accel+Alt+8","Accel+Alt+9"],"commands":["back","forward","force-reload","open-new-tab","close-tab","restore-last-tab","show-next-tab","show-previous-tab","mute-current-tab","bookmark-this-page","open-screen-capture","search-in-this-page"],"browserPages":[],"menus":[],"tabs":"","tabMarks":false,"tabValues":false,"windowValues":false,"prompt":false,"currentUrl":false,"openUrl":false,"webFrame":false,"chromeStyle":false,"ownPrefix":"noraneko.shortcut-keys.","name":"shortcut-keys","uuid":"004bc907-b750-4f9b-aa9c-c88036fff3e9","strings":{"en":{"hint":"Nine keys. Pick what each one does.","none":"(nothing)","cmd_back":"Back","cmd_forward":"Forward","cmd_force_reload":"Reload, skipping the cache","cmd_open_new_tab":"New tab","cmd_close_tab":"Close this tab","cmd_restore_last_tab":"Reopen the closed tab","cmd_show_next_tab":"Next tab","cmd_show_previous_tab":"Previous tab","cmd_mute_current_tab":"Mute / unmute this tab","cmd_bookmark_this_page":"Bookmark this page","cmd_open_screen_capture":"Take a screenshot","cmd_search_in_this_page":"Find in this page"},"ja":{"hint":"九つの鍵。それぞれに、することを一つ選ぶ。","none":"(なし)","cmd_back":"戻る","cmd_forward":"進む","cmd_force_reload":"キャッシュを使わずに読み込み直す","cmd_open_new_tab":"新しいタブ","cmd_close_tab":"このタブを閉じる","cmd_restore_last_tab":"閉じたタブを戻す","cmd_show_next_tab":"次のタブへ","cmd_show_previous_tab":"前のタブへ","cmd_mute_current_tab":"このタブの音を消す / 戻す","cmd_bookmark_this_page":"このページをブックマークする","cmd_open_screen_capture":"スクリーンショットを撮る","cmd_search_in_this_page":"このページの中を探す"}}}).catch((e) =>
    console.error("[shortcut-keys] failed:", e)
  );
});
shortcut-keys/ops/Style.tsubaki · 28 lines
# SPDX-License-Identifier: MPL-2.0
#
# 設定の一枚の形だけ。窓のほうには <key> しか置かないので、見た目は無い
# (生の CSS は一枚も出していない -- だから chrome_style の宣言も要らない)。
#
# ここは字ではなくデータ。CSS にするのは殻(_shared/style.ts)。

module Style
    pane() = Dict(:display => :flex, :flex_direction => :column, :gap => 8px)

    hint() = Dict(:margin => 0, :color => Theme.muted)

    row() = Dict(
        :display => :flex,
        :align_items => :center,
        :gap => 10px,
    )

    # 鍵の綴りは等幅で、幅を揃えて縦に並べる(九行が段になって読める)
    combo() = Dict(
        :font_family => "ui-monospace, SF Mono, Menlo, monospace",
        :min_width => 9em,
        :color => Theme.muted,
    )

    pick() = Dict(:flex => "1", :max_width => 22em)
end
shortcut-keys/ops/shortcut-keys.tsubaki · 175 lines
# SPDX-License-Identifier: MPL-2.0
#
# shortcut-keys: 九つの席と、そこに座る命令。
#
# 覚えているのは pref 一本(席 → 命令の名前)だけ。鍵が押されたら、その席に
# 座っている命令を一つ実行する -- それだけの drop で、logic に判断は無い。
#
#   setup()           九つの <key> を置く場所と、設定の一枚
#   start(facts)      pref に書いてある割り当て
#   dispatch(action)  鍵が押された / 設定で選ばれた
#
# **席が決め打ちなのは、宣言のせい**。drop が受け取れる鍵は drop.toml に書いた
# ものだけで、それは入れる人の画面に出る行でもある。「どの鍵でも割り当てられる」
# にすると、その行が書けなくなる(何を取るのか、入れる前に言えなくなる)。
# 九つは少ないけれど、**何を取るか言い切れる**ほうを選んだ。
#
# 命令の日本語は abi/v1.json にもある。ここに書き写しているのは、drop が実行時に
# abi を読めないから -- 英語のほうは abi にまだ無いので、どちらにせよ両方要る。
# abi が en を持ったら、殻が渡せるようになって、この十二行は消える。

PREF = "noraneko.shortcut-keys.bindings"

SLOTS = [
    "Accel+Alt+1", "Accel+Alt+2", "Accel+Alt+3",
    "Accel+Alt+4", "Accel+Alt+5", "Accel+Alt+6",
    "Accel+Alt+7", "Accel+Alt+8", "Accel+Alt+9"
]

import Style

# 座らせられる命令と、その字。drop.toml の [permissions] commands と同じ顔ぶれ
# (片方だけ増えても動かないので、check-drop が両方から数えて突き合わせる)。
choices() = [
    Dict("name" => "back", "label" => t(:cmd_back)),
    Dict("name" => "forward", "label" => t(:cmd_forward)),
    Dict("name" => "force-reload", "label" => t(:cmd_force_reload)),
    Dict("name" => "open-new-tab", "label" => t(:cmd_open_new_tab)),
    Dict("name" => "close-tab", "label" => t(:cmd_close_tab)),
    Dict("name" => "restore-last-tab", "label" => t(:cmd_restore_last_tab)),
    Dict("name" => "show-next-tab", "label" => t(:cmd_show_next_tab)),
    Dict("name" => "show-previous-tab", "label" => t(:cmd_show_previous_tab)),
    Dict("name" => "mute-current-tab", "label" => t(:cmd_mute_current_tab)),
    Dict("name" => "bookmark-this-page", "label" => t(:cmd_bookmark_this_page)),
    Dict("name" => "open-screen-capture", "label" => t(:cmd_open_screen_capture)),
    Dict("name" => "search-in-this-page", "label" => t(:cmd_search_in_this_page))
]

# --- setup --------------------------------------------------------------------

setup() = Setup(
    anchors = [
        Anchor(name = "keys", at = "keyset", id = "nora-shortcut-keys"),
        Anchor(name = "settings", at = "settings")
    ],
    prefs_json = [PREF]
)

@kwdef struct AppState
    bound = Dict()      # 席 => 命令の名前。座っていない席は、鍵も置かない
end

STATE = AppState()

command_at(s, slot) = get(s.bound, slot, "")
taken(s) = filter(slot -> command_at(s, slot) != "", SLOTS)

# --- view ---------------------------------------------------------------------
# 座っていない席には <key> を置かない。置いても何も起きないし、その組み合わせを
# ページに渡さないほうが、押した人の思ったとおりになる。

view(s) = Dict(
    "keys" => el("fragment", Dict(), [key_for(s, slot) for slot in taken(s)]),
    "settings" => pane(s)
)

key_for(s, slot) = el("key", Dict(
    "combo" => slot,
    "on:command" => Run(command_at(s, slot))
))

pane(s) = el("div", Dict("style" => Style.pane()),
    vcat([el("p", Dict("style" => Style.hint()), [t(:hint)])],
         [slot_row(s, slot) for slot in SLOTS]))

slot_row(s, slot) = el("label", Dict("style" => Style.row()), [
    el("span", Dict("style" => Style.combo()), [slot]),
    el("select", Dict("style" => Style.pick(), "value" => command_at(s, slot),
                      "on:change" => Bind(slot, "")),
        vcat([el("option", Dict("value" => ""), [t(:none)])],
             [option_for(c) for c in choices()]))
])

option_for(c) = el("option", Dict("value" => get(c, :name, "")), [get(c, :label, "")])

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

abstract type Action end

"鍵が押された。座っている命令を一つ"
struct Run <: Action
    name
end

"設定で選ばれた。name は __event から来る(こちらでは作らない)"
struct Bind <: Action
    slot
    name
end

"about:config で、あるいは別の窓で、割り当てが変わった"
struct PrefChanged <: Action
    name
    value
end

action_of(a) = begin
    kind = get(a, :__type, "")
    ev = get(a, :__event, Dict())
    if kind == "Run"
        Run(get(a, :name, ""))
    elseif kind == "Bind"
        Bind(get(a, :slot, ""), get(ev, :value, ""))
    elseif kind == "PrefChanged"
        PrefChanged(get(a, :name, ""), get(a, :value, nothing))
    else
        error("shortcut-keys: no action named " * kind)
    end
end

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

struct Step
    state
    effects
end

# 空の名前は何もしない -- 席が空くのと押されるのが行き違ったとき、ここで止まる
update(s::AppState, a::Run) = a.name == "" ? Step(s, []) : Step(s, [DoCommand(a.name)])

"d から鍵 k を外した、新しい Dict"
function without(d, k)
    n = copy(d)
    delete!(n, k)
    return n
end

# 空にしたら、その席を pref から **消す**(空の字が溜まっていくと、about:config で
# 見たときに「座っているのか、いないのか」が読み取れなくなる)
update(s::AppState, a::Bind) = begin
    bound = a.name == "" ? without(s.bound, a.slot) : put(s.bound, a.slot, a.name)
    Step(AppState(bound = bound), [SetPref(PREF, bound)])
end

function update(s::AppState, a::PrefChanged)
    if a.name != PREF
        return Step(s, [])
    end
    return Step(AppState(bound = a.value === nothing ? Dict() : a.value), [])
end

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

function start(facts)
    prefs = get(facts, :prefs, Dict())
    held = get(prefs, PREF, nothing)
    STATE = AppState(bound = held === nothing ? Dict() : held)
    return frame(view(STATE))
end

function dispatch(a)
    st = update(STATE, action_of(a))
    STATE = st.state
    return frame(view(STATE), st.effects)
end
shortcut-keys/strings.toml · 35 lines
# 言葉。logic は t(:鍵) と鍵で書き、どの字になるかは殻が Services.locale で決める。
# 命令の字は abi/v1.json にもあるが、drop は実行時に abi を読めないので、ここにも並べる。

[en]
hint = "Nine keys. Pick what each one does."
none = "(nothing)"
cmd_back = "Back"
cmd_forward = "Forward"
cmd_force_reload = "Reload, skipping the cache"
cmd_open_new_tab = "New tab"
cmd_close_tab = "Close this tab"
cmd_restore_last_tab = "Reopen the closed tab"
cmd_show_next_tab = "Next tab"
cmd_show_previous_tab = "Previous tab"
cmd_mute_current_tab = "Mute / unmute this tab"
cmd_bookmark_this_page = "Bookmark this page"
cmd_open_screen_capture = "Take a screenshot"
cmd_search_in_this_page = "Find in this page"

[ja]
hint = "九つの鍵。それぞれに、することを一つ選ぶ。"
none = "(なし)"
cmd_back = "戻る"
cmd_forward = "進む"
cmd_force_reload = "キャッシュを使わずに読み込み直す"
cmd_open_new_tab = "新しいタブ"
cmd_close_tab = "このタブを閉じる"
cmd_restore_last_tab = "閉じたタブを戻す"
cmd_show_next_tab = "次のタブへ"
cmd_show_previous_tab = "前のタブへ"
cmd_mute_current_tab = "このタブの音を消す / 戻す"
cmd_bookmark_this_page = "このページをブックマークする"
cmd_open_screen_capture = "スクリーンショットを撮る"
cmd_search_in_this_page = "このページの中を探す"

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.