Files
codium_nostr_extension/src/extension.ts
T
2026-07-18 17:29:20 -04:00

1231 lines
42 KiB
TypeScript

import * as vscode from "vscode";
import { bytesToHex, hexToBytes } from "nostr-tools/utils";
import { getPublicKey } from "nostr-tools/pure";
import { neventEncode, naddrEncode } from "nostr-tools/nip19";
import { LongFormArticle } from "nostr-tools/kinds";
import type { EventTemplate, VerifiedEvent } from "nostr-tools/core";
import { ConfigService } from "./config";
import { StateService } from "./state";
import { StatusBar } from "./ui/statusBar";
import { NostrSidebarProvider } from "./ui/sidebar";
import { LocalSigner } from "./signer/localSigner";
import { NsignerBackend } from "./signer/nsignerBackend";
import type { NostrSigner } from "./signer/backend";
import { decodeNsec, isHexSecretKey, hexToNpub } from "./nostr/npub";
import { validateEventTemplate } from "./nostr/validate";
import { publishToRelays, fetchUserRelays, fetchUserProfile, type PublishResult } from "./nostr/relay";
import { parseFrontMatter, FrontMatterError } from "./frontmatter";
import { buildLongFormEvent } from "./nostr/nip23";
import { discoverNsignerSockets } from "./nsigner/discovery";
import * as path from "node:path";
/** Event fired when identity state changes (sign-in / sign-out). */
const onStateChange = new vscode.EventEmitter<void>();
let sidebarProvider: NostrSidebarProvider | null = null;
export async function activate(context: vscode.ExtensionContext): Promise<void> {
const state = new StateService(context);
const config = new ConfigService();
const statusBar = new StatusBar(state);
context.subscriptions.push(statusBar);
context.subscriptions.push(onStateChange);
// Sidebar view provider.
sidebarProvider = new NostrSidebarProvider(state, config, onStateChange.event);
context.subscriptions.push(
vscode.window.registerWebviewViewProvider("nostr.sidebar", sidebarProvider, {
webviewOptions: { retainContextWhenHidden: true },
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("nostr.signIn", () =>
cmdSignIn(state, config, statusBar),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("nostr.signOut", () =>
cmdSignOut(state, statusBar),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("nostr.createUnsignedEvent", () =>
cmdCreateUnsignedEvent(),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("nostr.validateEventFile", () => cmdValidateEventFile()),
);
context.subscriptions.push(
vscode.commands.registerCommand("nostr.publishEventFile", () =>
cmdPublishEventFile(state, config),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("nostr.fetchRelays", () =>
cmdFetchRelays(state, config),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("nostr.publishLongForm", () =>
cmdPublishLongForm(state, config),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("nostr.validateFrontMatter", () =>
cmdValidateFrontMatter(),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("nostr.resetPublishedTimestamps", () =>
cmdResetTimestamps(state),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("nostr.selectBackend", () =>
cmdSelectBackend(config, state, statusBar),
),
);
context.subscriptions.push(
vscode.commands.registerCommand("nostr.signInFromSidebar", (method: string, cfg: Record<string, unknown>) =>
cmdSignInFromSidebar(method, cfg, state, config, statusBar),
),
);
// Zero the key on extension deactivate.
context.subscriptions.push({
dispose: () => state.clearKey(),
});
}
/** Notify the sidebar (and any other listeners) that identity state changed. */
function fireStateChange(): void {
onStateChange.fire();
}
export function deactivate(): void {
// Disposal handled by subscriptions in activate().
}
// ---------------------------------------------------------------------------
// Sign in / out
// ---------------------------------------------------------------------------
async function cmdSignIn(
state: StateService,
config: ConfigService,
statusBar: StatusBar,
): Promise<void> {
const input = await vscode.window.showInputBox({
prompt: "Enter your Nostr secret key (nsec1... or 64-char hex)",
password: true,
ignoreFocusOut: true,
placeHolder: "nsec1... or hex",
});
if (!input) {
return;
}
let secretKey: Uint8Array;
try {
const trimmed = input.trim();
if (trimmed.startsWith("nsec1")) {
secretKey = decodeNsec(trimmed);
} else if (isHexSecretKey(trimmed)) {
secretKey = hexToBytes(trimmed);
} else {
vscode.window.showErrorMessage(
"Invalid secret key: must be an nsec1... string or 64-char hex.",
);
return;
}
} catch (err) {
vscode.window.showErrorMessage(`Failed to decode secret key: ${(err as Error).message}`);
return;
}
if (secretKey.length !== 32) {
vscode.window.showErrorMessage(
`Invalid secret key: expected 32 bytes, got ${secretKey.length}.`,
);
return;
}
let pubkeyHex: string;
try {
pubkeyHex = getPublicKey(secretKey);
} catch (err) {
vscode.window.showErrorMessage(
`Failed to derive public key: ${(err as Error).message}`,
);
return;
}
state.setKey(secretKey, pubkeyHex);
statusBar.refresh();
fireStateChange();
vscode.window.showInformationMessage(`Signed in as ${hexToNpub(pubkeyHex)}`);
// Fetch the user's NIP-65 relay list (kind 10002) and replace the sidebar's
// relay set with it. Falls back silently to the configured defaults if no
// 10002 event is found or the fetch fails.
await fetchAndApplyRelays(pubkeyHex, config, /*showErrors=*/ false);
// Fetch the user's kind 0 profile (avatar + name) for the sidebar.
await fetchAndDisplayAvatar(pubkeyHex, config);
}
/** Fetch the user's NIP-65 relay list and apply it to the sidebar. */
async function fetchAndApplyRelays(
pubkeyHex: string,
config: ConfigService,
showErrors: boolean,
): Promise<void> {
const provider = sidebarProvider;
if (!provider) {
return;
}
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Window,
title: "Fetching your NIP-65 relay list…",
cancellable: false,
},
async () => {
try {
const userRelays = await fetchUserRelays(pubkeyHex, config.relays);
if (userRelays && userRelays.length > 0) {
// Use write-capable relays for publishing; if none are marked
// write, fall back to all of them.
const writeRelays = userRelays.filter((r) => r.write).map((r) => r.url);
const urls = writeRelays.length > 0 ? writeRelays : userRelays.map((r) => r.url);
provider.setRelays(urls);
vscode.window.showInformationMessage(
`Loaded ${urls.length} relay(s) from your NIP-65 relay list.`,
);
} else if (showErrors) {
vscode.window.showWarningMessage(
"No NIP-65 relay list (kind 10002) found for your pubkey on the configured relays.",
);
}
} catch (err) {
const msg = (err as Error).message;
if (showErrors) {
vscode.window.showErrorMessage(`Failed to fetch NIP-65 relay list: ${msg}`);
} else {
console.warn("NIP-65 relay fetch failed:", msg);
}
}
},
);
}
/** Command: manually fetch the user's NIP-65 relay list. */
async function cmdFetchRelays(state: StateService, config: ConfigService): Promise<void> {
if (!state.isSignedIn()) {
const action = await vscode.window.showErrorMessage(
"Sign in to Nostr first to fetch your relay list.",
"Sign In",
);
if (action === "Sign In") {
await vscode.commands.executeCommand("nostr.signIn");
}
if (!state.isSignedIn()) {
return;
}
}
await fetchAndApplyRelays(state.getPubkeyHex()!, config, /*showErrors=*/ true);
}
async function cmdSignOut(state: StateService, statusBar: StatusBar): Promise<void> {
state.clearKey();
statusBar.refresh();
fireStateChange();
vscode.window.showInformationMessage("Signed out of Nostr.");
}
// ---------------------------------------------------------------------------
// Create unsigned event
// ---------------------------------------------------------------------------
const COMMON_KINDS: { label: string; kind: number; detail: string }[] = [
{ label: "1 — Short Text Note", kind: 1, detail: "A simple text note. Fill in content." },
{ label: "30023 — Long-Form Article", kind: LongFormArticle, detail: "NIP-23 long-form post with d/title/summary/image/tags + markdown body." },
{ label: "10002 — Relay List", kind: 10002, detail: "NIP-65 relay list with r tags (read/write markers)." },
{ label: "0 — Metadata (Profile)", kind: 0, detail: "NIP-01 profile: name/about/picture/nip05 as JSON content." },
{ label: "3 — Contacts", kind: 3, detail: "NIP-02 contact list with p tags (pubkey/relay/petname)." },
];
async function cmdCreateUnsignedEvent(): Promise<void> {
const custom = { label: "Custom…", kind: -1, detail: "Enter any kind integer." };
const picked = await vscode.window.showQuickPick([...COMMON_KINDS, custom], {
placeHolder: "Select an event kind",
ignoreFocusOut: true,
});
if (!picked) {
return;
}
let kind: number;
if (picked.kind === -1) {
const input = await vscode.window.showInputBox({
prompt: "Enter event kind (non-negative integer)",
ignoreFocusOut: true,
validateInput: (v) => (/^\d+$/.test(v.trim()) ? null : "must be a non-negative integer"),
});
if (!input) {
return;
}
kind = parseInt(input.trim(), 10);
} else {
kind = picked.kind;
}
// Long-form articles are authored as markdown with YAML front-matter.
if (kind === LongFormArticle) {
const md = buildLongFormMarkdownSkeleton();
const doc = await vscode.workspace.openTextDocument({ content: md, language: "markdown" });
await vscode.window.showTextDocument(doc);
vscode.window.showInformationMessage(
`Created long-form article skeleton (.md with YAML front-matter). Edit the body, then run "Nostr: Publish Current File as Long-Form Note".`,
);
return;
}
// Other kinds: JSON skeleton.
const template = buildSkeleton(kind);
const content = JSON.stringify(template, null, 2) + "\n";
const doc = await vscode.workspace.openTextDocument({ content, language: "json" });
await vscode.window.showTextDocument(doc);
const kindName = template.tags.find((t) => t[0] === "alt")?.[1] ?? `kind=${kind}`;
vscode.window.showInformationMessage(
`Created ${kindName} skeleton. Fill in the placeholder values, then run "Nostr: Publish Event from JSON File".`,
);
}
/**
* Build a long-form article skeleton as a markdown document with YAML
* front-matter. This is the natural authoring format for NIP-23 notes —
* the user writes markdown, and the extension parses the front-matter at
* publish time.
*/
function buildLongFormMarkdownSkeleton(): string {
return `---
slug: my-article-slug
title: My Article Title
summary: A one-sentence abstract for feeds.
image: https://example.com/cover.png
tags: [nostr, writing]
---
# My Article Title
Write your article in Markdown here.
## Section
Body text…
`;
}
/**
* Build a kind-specific skeleton EventTemplate with placeholder tags and
* content, so the user can see exactly which fields the event kind expects.
*
* - kind 1 (ShortTextNote): empty content, no tags.
* - kind 30023 (LongFormArticle): d/title/summary/image/published_at/t tags
* + markdown body placeholder.
* - kind 10002 (RelayList): r tags with read/write markers.
* - kind 0 (Metadata): name/about/picture/npub JSON content.
* - kind 3 (Contacts): empty contact list placeholder.
* - other: bare template with empty tags/content.
*/
function buildSkeleton(kind: number): EventTemplate {
const now = Math.floor(Date.now() / 1000);
switch (kind) {
case 1: // ShortTextNote
return {
kind: 1,
created_at: now,
tags: [],
content: "What's happening?",
};
case LongFormArticle: // 30023
return {
kind: LongFormArticle,
created_at: now,
tags: [
["d", "my-article-slug"],
["title", "My Article Title"],
["summary", "A one-sentence abstract for feeds."],
["image", "https://example.com/cover.png"],
["published_at", String(now)],
["t", "nostr"],
["t", "writing"],
["alt", "Long-form article"],
],
content: "# My Article Title\n\nWrite your article in Markdown here.\n\n## Section\n\nBody text…",
};
case 10002: // RelayList (NIP-65)
return {
kind: 10002,
created_at: now,
tags: [
["r", "wss://relay.damus.io", "read"],
["r", "wss://relay.damus.io", "write"],
["r", "wss://relay.primal.net", "read"],
["r", "wss://relay.primal.net", "write"],
["r", "wss://laantungir.net/relay", "write"],
["alt", "Relay list"],
],
content: "",
};
case 0: // Metadata (NIP-01)
return {
kind: 0,
created_at: now,
tags: [["alt", "Profile metadata"]],
content: JSON.stringify(
{
name: "your-name",
about: "A short bio.",
picture: "https://example.com/avatar.png",
nip05: "your-name@example.com",
},
null,
2,
),
};
case 3: // Contacts (NIP-02)
return {
kind: 3,
created_at: now,
tags: [
// ["p", "<pubkey-hex>", "<optional-relay-url>", "<optional-petname>"]
["p", "0000000000000000000000000000000000000000000000000000000000000000", "", "example-petname"],
["alt", "Contact list"],
],
content: "",
};
default:
return {
kind,
created_at: now,
tags: [["alt", `kind=${kind}`]],
content: "",
};
}
}
// ---------------------------------------------------------------------------
// Validate event file
// ---------------------------------------------------------------------------
async function cmdValidateEventFile(): Promise<void> {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage("No active JSON editor.");
return;
}
const text = editor.document.getText();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (err) {
vscode.window.showErrorMessage(`Invalid JSON: ${(err as Error).message}`);
return;
}
const result = validateEventTemplate(parsed);
if (result.valid) {
const obj = parsed as { kind: number; tags: unknown[]; content: string };
const kindLabel = result.kindName ? `${obj.kind} (${result.kindName})` : `${obj.kind}`;
const tags = obj.tags;
const content = obj.content;
vscode.window.showInformationMessage(
`Valid unsigned event. kind=${kindLabel} tags=${tags.length} content=${content.length} chars`,
);
} else {
vscode.window.showErrorMessage(`Invalid event: ${result.errors.join("; ")}`);
}
if (result.warnings.length > 0) {
vscode.window.showWarningMessage(result.warnings.join("; "));
}
}
// ---------------------------------------------------------------------------
// Publish event file
// ---------------------------------------------------------------------------
async function cmdPublishEventFile(state: StateService, config: ConfigService): Promise<void> {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage("No active JSON editor.");
return;
}
// Require a signed-in signer.
let signer = getSigner(state, config);
if (!signer) {
const action = await vscode.window.showErrorMessage(
"Sign in to Nostr first before publishing.",
"Sign In",
);
if (action === "Sign In") {
await vscode.commands.executeCommand("nostr.signIn");
}
signer = getSigner(state, config);
if (!signer) {
return;
}
}
// Parse + validate the active JSON document.
const text = editor.document.getText();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (err) {
vscode.window.showErrorMessage(`Invalid JSON: ${(err as Error).message}`);
return;
}
const result = validateEventTemplate(parsed);
if (!result.valid) {
vscode.window.showErrorMessage(`Invalid event: ${result.errors.join("; ")}`);
return;
}
if (result.warnings.length > 0) {
const proceed = await vscode.window.showWarningMessage(
`${result.warnings.join("; ")}\n\nProceed with publish?`,
"Publish",
"Cancel",
);
if (proceed !== "Publish") {
return;
}
}
// Build the EventTemplate (strip any signed fields the user may have left in).
const obj = parsed as Record<string, unknown>;
const template: EventTemplate = {
kind: obj.kind as number,
created_at: obj.created_at as number,
tags: obj.tags as string[][],
content: obj.content as string,
};
// Sign.
let signed: VerifiedEvent;
try {
signed = await signer.signEvent(template);
} catch (err) {
vscode.window.showErrorMessage(`Signing failed: ${(err as Error).message}`);
return;
}
// Use the sidebar's enabled-relay set if available; otherwise fall back to
// all configured relays.
const relays = sidebarProvider ? sidebarProvider.getEnabledRelays() : config.relays;
if (relays.length === 0) {
vscode.window.showErrorMessage(
"No relays enabled. Toggle relays on in the Nostr sidebar or set `nostr.relays` in settings.",
);
return;
}
const selectedRelays = config.publishConfirm
? await confirmPublish(signed, relays, signer)
: relays;
if (selectedRelays.length === 0) {
vscode.window.showInformationMessage("Publish cancelled: no relays selected.");
return;
}
// Publish.
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Publishing Nostr event…",
cancellable: false,
},
async () => {
const results = await publishToRelays(signed, selectedRelays, config.publishTimeoutMs);
showPublishResults(signed, results);
},
);
}
function getSigner(state: StateService, config: ConfigService): NostrSigner | null {
if (config.signerBackend === "nsigner") {
return new NsignerBackend(buildNsignerConfig(config, state));
}
const sk = state.getSecretKey();
if (!sk) {
return null;
}
return new LocalSigner(sk);
}
/**
* Build an NsignerBackendConfig from extension config + session state.
* For TCP, the caller secret key is needed for auth envelopes — it's taken
* from the session state (the signed-in local key) or a configured caller nsec.
*/
function buildNsignerConfig(config: ConfigService, state: StateService): import("./signer/nsignerBackend").NsignerBackendConfig {
const cfg: import("./signer/nsignerBackend").NsignerBackendConfig = {
transport: config.nsignerTransport,
socketName: config.nsignerSocketName,
qrexecQube: config.nsignerQrexecQube,
qrexecService: config.nsignerQrexecService,
tcpHost: config.nsignerTcpHost,
tcpPort: config.nsignerTcpPort,
nostrIndex: config.nsignerNostrIndex,
};
if (config.nsignerTransport === "tcp") {
// Prefer the session key (if signed in locally) as the caller key;
// otherwise try the configured caller nsec.
const sessionKey = state.getSecretKey();
if (sessionKey) {
cfg.callerSecretKey = sessionKey;
} else {
const nsec = config.nsignerCallerNsec;
if (nsec) {
try {
cfg.callerSecretKey = nsec.startsWith("nsec1")
? decodeNsec(nsec)
: hexToBytes(nsec);
} catch {
// leave undefined — TCP will error with a clear message
}
}
}
}
return cfg;
}
async function confirmPublish(
signed: VerifiedEvent,
relays: string[],
signer: NostrSigner,
): Promise<string[]> {
const npub = hexToNpub(signed.pubkey);
const previewItems: vscode.QuickPickItem[] = [
{ label: `kind: ${signed.kind}` },
{ label: `pubkey: ${npub}` },
{ label: `id: ${signed.id}` },
{ label: `tags: ${signed.tags.length}` },
{ label: `content: ${signed.content.length} chars` },
{ label: `signer: ${signer.describe()}` },
{ label: `relays: ${relays.join(", ")}` },
{ label: "",
},
];
const relayItems: vscode.QuickPickItem[] = relays.map((r) => ({
label: r,
picked: true,
}));
const pick = await vscode.window.showQuickPick([...previewItems, ...relayItems], {
canPickMany: true,
placeHolder: "Preview signed event. Toggle relays to publish, then confirm.",
ignoreFocusOut: true,
title: "Confirm Nostr Publish",
});
if (!pick) {
return [];
}
// Selected relays are the relay-URL items the user left checked.
const selected = pick
.filter((item) => item.label.startsWith("wss://") || item.label.startsWith("ws://"))
.map((item) => item.label);
if (selected.length === 0) {
return [];
}
const confirm = await vscode.window.showQuickPick(["Publish", "Cancel"], {
placeHolder: `Publish to ${selected.length} relay(s)?`,
ignoreFocusOut: true,
});
return confirm === "Publish" ? selected : [];
}
function showPublishResults(signed: VerifiedEvent, results: PublishResult[]): void {
const ok = results.filter((r) => r.ok).length;
const failed = results.length - ok;
const lines = results.map((r) => ` ${r.ok ? "✓" : "✗"} ${r.relay} — ${r.message}`);
const summary = `Published ${ok}/${results.length} relay(s)${failed ? `, ${failed} failed` : ""}.\nEvent id: ${signed.id}\n\n${lines.join("\n")}`;
if (failed === 0) {
vscode.window.showInformationMessage(summary, "Copy Event ID").then((action) => {
if (action === "Copy Event ID") {
vscode.env.clipboard.writeText(signed.id);
}
});
} else {
vscode.window.showWarningMessage(summary, "Copy Event ID").then((action) => {
if (action === "Copy Event ID") {
vscode.env.clipboard.writeText(signed.id);
}
});
}
}
// ---------------------------------------------------------------------------
// Phase 1: Long-form note (.md) publishing
// ---------------------------------------------------------------------------
/** Derive a filename stem from the active editor's filename (without extension). */
function activeFilenameStem(): string | undefined {
const editor = vscode.window.activeTextEditor;
if (!editor) return undefined;
const fsPath = editor.document.fileName;
if (!fsPath || fsPath.startsWith("Untitled:")) return undefined;
return path.basename(fsPath, path.extname(fsPath));
}
/** Command: validate the front-matter of the active .md document. */
async function cmdValidateFrontMatter(): Promise<void> {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage("No active Markdown editor.");
return;
}
if (editor.document.languageId !== "markdown") {
vscode.window.showErrorMessage("Active file is not Markdown.");
return;
}
const text = editor.document.getText();
const stem = activeFilenameStem();
let result;
try {
result = parseFrontMatter(text, stem);
} catch (err) {
if (err instanceof FrontMatterError) {
vscode.window.showErrorMessage(`Front-matter error: ${err.message}`);
} else {
vscode.window.showErrorMessage(`Failed to parse front-matter: ${(err as Error).message}`);
}
return;
}
const fm = result.frontMatter;
const lines = [
`d: ${fm.slug}`,
`title: ${fm.title}`,
`summary: ${fm.summary.slice(0, 60)}${fm.summary.length > 60 ? "…" : ""}`,
`image: ${fm.image ?? "(none)"}`,
`tags: ${fm.tags.join(", ") || "(none)"}`,
`body: ${result.content.length} chars`,
];
vscode.window.showInformationMessage(lines.join("\n"));
if (result.warnings.length > 0) {
vscode.window.showWarningMessage(result.warnings.join("; "));
}
}
/** Command: publish the active .md file as a NIP-23 long-form note. */
async function cmdPublishLongForm(state: StateService, config: ConfigService): Promise<void> {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage("No active Markdown editor.");
return;
}
if (editor.document.languageId !== "markdown") {
vscode.window.showErrorMessage("Active file is not Markdown.");
return;
}
// Require sign-in.
let signer = getSigner(state, config);
if (!signer) {
const action = await vscode.window.showErrorMessage(
"Sign in to Nostr first before publishing.",
"Sign In",
);
if (action === "Sign In") {
await vscode.commands.executeCommand("nostr.signIn");
}
signer = getSigner(state, config);
if (!signer) {
return;
}
}
// Parse front-matter.
const text = editor.document.getText();
const stem = activeFilenameStem();
let parsed;
try {
parsed = parseFrontMatter(text, stem);
} catch (err) {
if (err instanceof FrontMatterError) {
vscode.window.showErrorMessage(`Front-matter error: ${err.message}`);
} else {
vscode.window.showErrorMessage(`Failed to parse front-matter: ${(err as Error).message}`);
}
return;
}
if (parsed.warnings.length > 0) {
const proceed = await vscode.window.showWarningMessage(
`${parsed.warnings.join("; ")}\n\nProceed with publish?`,
"Publish",
"Cancel",
);
if (proceed !== "Publish") {
return;
}
}
// Resolve published_at: reuse stored value if this slug was published before,
// otherwise set it to now.
const slug = parsed.frontMatter.slug;
const existing = state.getPublishedAt(slug);
const publishedAt = existing ?? Math.floor(Date.now() / 1000);
const isNew = existing === null;
// Build the event.
const template = buildLongFormEvent({
meta: parsed.frontMatter,
content: parsed.content,
publishedAt,
});
// Validate the built template (reuse Phase 0 validator).
const validation = validateEventTemplate(template);
if (!validation.valid) {
vscode.window.showErrorMessage(`Built event is invalid: ${validation.errors.join("; ")}`);
return;
}
// Sign.
let signed: VerifiedEvent;
try {
signed = await signer.signEvent(template);
} catch (err) {
vscode.window.showErrorMessage(`Signing failed: ${(err as Error).message}`);
return;
}
// Relays.
const relays = sidebarProvider ? sidebarProvider.getEnabledRelays() : config.relays;
if (relays.length === 0) {
vscode.window.showErrorMessage(
"No relays enabled. Toggle relays in the Nostr sidebar or set `nostr.relays` in settings.",
);
return;
}
// Confirmation dialog.
const selectedRelays = config.publishConfirm
? await confirmLongFormPublish(signed, relays, signer, slug, isNew, parsed.frontMatter)
: relays;
if (selectedRelays.length === 0) {
vscode.window.showInformationMessage("Publish cancelled: no relays selected.");
return;
}
// Publish.
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Publishing long-form note…",
cancellable: false,
},
async () => {
const results = await publishToRelays(signed, selectedRelays, config.publishTimeoutMs);
// Store published_at on success (first publish only).
if (isNew) {
const anyOk = results.some((r) => r.ok);
if (anyOk) {
state.setPublishedAt(slug, publishedAt);
}
}
showPublishResults(signed, results);
},
);
}
/** Confirmation dialog for long-form publish with NIP-23 preview fields. */
async function confirmLongFormPublish(
signed: VerifiedEvent,
relays: string[],
signer: NostrSigner,
slug: string,
isNew: boolean,
meta: { title: string; summary: string; image?: string; tags: string[] },
): Promise<string[]> {
const npub = hexToNpub(signed.pubkey);
const previewItems: vscode.QuickPickItem[] = [
{ label: `d: ${slug}` },
{ label: `title: ${meta.title}` },
{ label: `summary: ${meta.summary.slice(0, 60)}${meta.summary.length > 60 ? "…" : ""}` },
{ label: `image: ${meta.image ?? "(none)"}` },
{ label: `tags: ${meta.tags.join(", ") || "(none)"}` },
{ label: `published_at: ${isNew ? "NEW (set to now)" : "UPDATE (preserving original)"}` },
{ label: `body: ${signed.content.length} chars` },
{ label: `pubkey: ${npub}` },
{ label: `id: ${signed.id}` },
{ label: `signer: ${signer.describe()}` },
{ label: `relays: ${relays.join(", ")}` },
{ label: "" },
];
const relayItems: vscode.QuickPickItem[] = relays.map((r) => ({ label: r, picked: true }));
const pick = await vscode.window.showQuickPick([...previewItems, ...relayItems], {
canPickMany: true,
placeHolder: "Preview long-form note. Toggle relays, then confirm.",
ignoreFocusOut: true,
title: "Confirm NIP-23 Publish",
});
if (!pick) return [];
const selected = pick
.filter((item) => item.label.startsWith("wss://") || item.label.startsWith("ws://"))
.map((item) => item.label);
if (selected.length === 0) return [];
const confirm = await vscode.window.showQuickPick(["Publish", "Cancel"], {
placeHolder: `Publish to ${selected.length} relay(s)?`,
ignoreFocusOut: true,
});
return confirm === "Publish" ? selected : [];
}
/** Command: reset all stored published_at timestamps. */
async function cmdResetTimestamps(state: StateService): Promise<void> {
const confirm = await vscode.window.showWarningMessage(
"Reset all stored first-publish timestamps? This only affects the local tracking map, not events already on relays.",
"Reset",
"Cancel",
);
if (confirm !== "Reset") return;
state.resetPublishedTimestamps();
vscode.window.showInformationMessage("Published timestamps reset.");
}
/**
* Command: sign in from the sidebar's method dropdown.
*
* Handles all four sign-in methods:
* - local: nsec/hex → LocalSigner
* - nsigner-unix: persist transport=unix + socketName, connect, get pubkey
* - nsigner-qrexec: persist transport=qrexec + qube, connect, get pubkey
* - nsigner-tcp: persist transport=tcp + host/port/callerNsec, connect, get pubkey
*
* After successful sign-in, fetches the user's kind 0 profile (avatar + name)
* and updates the sidebar.
*/
async function cmdSignInFromSidebar(
method: string,
cfg: Record<string, unknown>,
state: StateService,
config: ConfigService,
statusBar: StatusBar,
): Promise<void> {
const vscodeCfg = vscode.workspace.getConfiguration("nostr");
if (method === "local") {
// Local sign-in: reuse the existing cmdSignIn flow with the provided nsec.
const nsec = typeof cfg.nsec === "string" ? cfg.nsec : "";
if (!nsec) {
vscode.window.showErrorMessage("Enter your nsec or hex secret key.");
return;
}
// Derive key directly here (avoid the InputBox prompt).
let secretKey: Uint8Array;
try {
if (nsec.startsWith("nsec1")) {
secretKey = decodeNsec(nsec);
} else if (isHexSecretKey(nsec)) {
secretKey = hexToBytes(nsec);
} else {
vscode.window.showErrorMessage("Invalid secret key: must be nsec1... or 64-char hex.");
return;
}
} catch (err) {
vscode.window.showErrorMessage(`Failed to decode secret key: ${(err as Error).message}`);
return;
}
if (secretKey.length !== 32) {
vscode.window.showErrorMessage(`Invalid secret key: expected 32 bytes, got ${secretKey.length}.`);
return;
}
const pubkeyHex = getPublicKey(secretKey);
state.setKey(secretKey, pubkeyHex);
await vscodeCfg.update("signerBackend", "local", vscode.ConfigurationTarget.Global);
statusBar.refresh();
fireStateChange();
vscode.window.showInformationMessage(`Signed in as ${hexToNpub(pubkeyHex)}`);
await fetchAndApplyRelays(pubkeyHex, config, false);
await fetchAndDisplayAvatar(pubkeyHex, config);
return;
}
// n_signer methods: persist transport + config, then connect.
const nostrIndex = typeof cfg.nostrIndex === "number" ? cfg.nostrIndex : 0;
let transport: "unix" | "qrexec" | "tcp";
if (method === "nsigner-unix") transport = "unix";
else if (method === "nsigner-qrexec") transport = "qrexec";
else if (method === "nsigner-tcp") transport = "tcp";
else {
vscode.window.showErrorMessage(`Unknown sign-in method: ${method}`);
return;
}
// Persist transport + index.
await vscodeCfg.update("nsigner.transport", transport, vscode.ConfigurationTarget.Global);
await vscodeCfg.update("nsigner.nostrIndex", nostrIndex, vscode.ConfigurationTarget.Global);
// Persist transport-specific config.
if (transport === "unix") {
const socketName = typeof cfg.socketName === "string" ? cfg.socketName : "nsigner";
await vscodeCfg.update("nsigner.socketName", socketName, vscode.ConfigurationTarget.Global);
} else if (transport === "qrexec") {
const qube = typeof cfg.qrexecQube === "string" ? cfg.qrexecQube : "nostr_signer";
await vscodeCfg.update("nsigner.qrexecQube", qube, vscode.ConfigurationTarget.Global);
} else if (transport === "tcp") {
const host = typeof cfg.tcpHost === "string" ? cfg.tcpHost : "127.0.0.1";
const port = typeof cfg.tcpPort === "number" ? cfg.tcpPort : 8080;
const callerNsec = typeof cfg.callerNsec === "string" ? cfg.callerNsec : "";
await vscodeCfg.update("nsigner.tcpHost", host, vscode.ConfigurationTarget.Global);
await vscodeCfg.update("nsigner.tcpPort", port, vscode.ConfigurationTarget.Global);
if (callerNsec) {
await vscodeCfg.update("nsigner.callerNsec", callerNsec, vscode.ConfigurationTarget.Global);
}
}
await vscodeCfg.update("signerBackend", "nsigner", vscode.ConfigurationTarget.Global);
// Build the backend config and try to connect.
// Re-read config to pick up the persisted values.
const backendConfig = buildNsignerConfig(new ConfigService(), state);
const backend = new NsignerBackend(backendConfig);
const startHint = transport === "unix"
? `nsigner --listen unix --socket-name ${backendConfig.socketName}`
: transport === "qrexec"
? `qrexec-client-vm ${backendConfig.qrexecQube} qubes.NsignerRpc`
: `nsigner --listen tcp:${backendConfig.tcpHost}:${backendConfig.tcpPort}`;
try {
const pubkey = await backend.getPublicKey();
state.setKey(new Uint8Array(32), pubkey); // dummy key — n_signer holds the real one
statusBar.refresh();
fireStateChange();
vscode.window.showInformationMessage(
`Signed in via ${backend.describe()}. Pubkey: ${hexToNpub(pubkey)}`,
);
await fetchAndApplyRelays(pubkey, config, false);
await fetchAndDisplayAvatar(pubkey, config);
} catch (err) {
statusBar.refresh();
fireStateChange();
vscode.window.showErrorMessage(
`Could not reach n_signer: ${(err as Error).message}\nStart it with: ${startHint}`,
);
}
}
/** Fetch the user's kind 0 profile and update the sidebar with avatar + name. */
async function fetchAndDisplayAvatar(pubkeyHex: string, config: ConfigService): Promise<void> {
const provider = sidebarProvider;
if (!provider) return;
try {
const profile = await fetchUserProfile(pubkeyHex, config.relays);
provider.setProfile(profile?.name, profile?.picture);
} catch {
// Non-fatal — avatar is optional.
}
}
/**
* Command: select the signer backend (local in-memory key vs n_signer).
*
* For `nsigner`, discovers running n_signer sockets via `/proc/net/unix` and
* lets the user pick one (or use the configured default). Surfaces connection
* errors with actionable hints.
*/
async function cmdSelectBackend(
config: ConfigService,
state: StateService,
statusBar: StatusBar,
): Promise<void> {
const current = config.signerBackend;
const items: (vscode.QuickPickItem & { backend: "local" | "nsigner" })[] = [
{
label: "Local (in-memory key)",
description: current === "local" ? "$(check) current" : "",
detail: "Sign with a secret key entered via Nostr: Sign In. Key held in RAM only.",
backend: "local",
},
{
label: "n_signer (remote signing)",
description: current === "nsigner" ? "$(check) current" : "",
detail: "Delegate signing to a running n_signer over an abstract Unix socket.",
backend: "nsigner",
},
];
const picked = await vscode.window.showQuickPick(items, {
placeHolder: "Select a signer backend",
ignoreFocusOut: true,
});
if (!picked) return;
if (picked.backend === "local") {
await vscode.workspace.getConfiguration("nostr").update(
"signerBackend",
"local",
vscode.ConfigurationTarget.Global,
);
statusBar.refresh();
fireStateChange();
vscode.window.showInformationMessage("Signer backend: local (in-memory key).");
return;
}
// n_signer: pick a transport, then configure it.
const transportItems: (vscode.QuickPickItem & { transport: "unix" | "qrexec" | "tcp" })[] = [
{
label: "Unix socket (local)",
description: config.nsignerTransport === "unix" ? "$(check) current" : "",
detail: "Abstract Unix socket. Same machine. No auth envelope (UID identity).",
transport: "unix",
},
{
label: "Qubes qrexec (inter-qube)",
description: config.nsignerTransport === "qrexec" ? "$(check) current" : "",
detail: "Spawn qrexec-client-vm to reach n_signer in another qube. No auth envelope.",
transport: "qrexec",
},
{
label: "TCP (remote / FIPS mesh)",
description: config.nsignerTransport === "tcp" ? "$(check) current" : "",
detail: "Connect to nsigner --listen tcp:host:port. Requires auth envelope (caller key).",
transport: "tcp",
},
];
const transportPicked = await vscode.window.showQuickPick(transportItems, {
placeHolder: "Select an n_signer transport",
ignoreFocusOut: true,
});
if (!transportPicked) return;
const transport = transportPicked.transport;
// Per-transport configuration.
let socketName = config.nsignerSocketName;
let qrexecQube = config.nsignerQrexecQube;
let tcpHost = config.nsignerTcpHost;
let tcpPort = config.nsignerTcpPort;
if (transport === "unix") {
const discovered = await discoverNsignerSockets();
const socketItems: (vscode.QuickPickItem & { socketName: string })[] = [];
socketItems.push({
label: `@${config.nsignerSocketName}`,
description: discovered.includes(config.nsignerSocketName) ? "reachable" : "not found in /proc/net/unix",
detail: "Configured socket name.",
socketName: config.nsignerSocketName,
});
for (const name of discovered) {
if (name !== config.nsignerSocketName) {
socketItems.push({ label: `@${name}`, description: "discovered", detail: "Found in /proc/net/unix.", socketName: name });
}
}
socketItems.push({ label: "Enter socket name manually…", description: "", detail: "Specify an abstract socket name (without @).", socketName: "" });
const socketPicked = await vscode.window.showQuickPick(socketItems, {
placeHolder: discovered.length === 0
? "No n_signer sockets found. Start one with `nsigner --listen unix --socket-name nsigner`."
: "Select an n_signer socket",
ignoreFocusOut: true,
});
if (!socketPicked) return;
if (!socketPicked.socketName) {
const input = await vscode.window.showInputBox({
prompt: "Enter n_signer abstract socket name (without @)",
value: config.nsignerSocketName,
ignoreFocusOut: true,
});
if (!input) return;
socketName = input.trim();
} else {
socketName = socketPicked.socketName;
}
} else if (transport === "qrexec") {
const input = await vscode.window.showInputBox({
prompt: "Enter target qube name (e.g. nostr_signer)",
value: config.nsignerQrexecQube,
ignoreFocusOut: true,
});
if (!input) return;
qrexecQube = input.trim();
} else if (transport === "tcp") {
const hostInput = await vscode.window.showInputBox({
prompt: "Enter n_signer TCP host",
value: config.nsignerTcpHost,
ignoreFocusOut: true,
});
if (!hostInput) return;
tcpHost = hostInput.trim();
const portInput = await vscode.window.showInputBox({
prompt: "Enter n_signer TCP port",
value: String(config.nsignerTcpPort),
ignoreFocusOut: true,
validateInput: (v) => (/^\d+$/.test(v.trim()) ? null : "must be a number"),
});
if (!portInput) return;
tcpPort = parseInt(portInput.trim(), 10);
}
// Persist transport-specific config + backend.
const cfg = vscode.workspace.getConfiguration("nostr");
await cfg.update("nsigner.transport", transport, vscode.ConfigurationTarget.Global);
if (transport === "unix") {
await cfg.update("nsigner.socketName", socketName, vscode.ConfigurationTarget.Global);
} else if (transport === "qrexec") {
await cfg.update("nsigner.qrexecQube", qrexecQube, vscode.ConfigurationTarget.Global);
} else if (transport === "tcp") {
await cfg.update("nsigner.tcpHost", tcpHost, vscode.ConfigurationTarget.Global);
await cfg.update("nsigner.tcpPort", tcpPort, vscode.ConfigurationTarget.Global);
}
await cfg.update("signerBackend", "nsigner", vscode.ConfigurationTarget.Global);
// Preflight: try to reach n_signer and fetch the pubkey.
const backend = new NsignerBackend(buildNsignerConfig(config, state));
const startHint = transport === "unix"
? `nsigner --listen unix --socket-name ${socketName}`
: transport === "qrexec"
? `qrexec-client-vm ${qrexecQube} qubes.NsignerRpc (ensure service is installed in ${qrexecQube})`
: `nsigner --listen tcp:${tcpHost}:${tcpPort}`;
try {
const pubkey = await backend.getPublicKey();
state.setKey(new Uint8Array(32), pubkey); // dummy key — n_signer holds the real one
statusBar.refresh();
fireStateChange();
vscode.window.showInformationMessage(
`Signer backend: ${backend.describe()}. Pubkey: ${hexToNpub(pubkey)}`,
);
} catch (err) {
statusBar.refresh();
fireStateChange();
vscode.window.showErrorMessage(
`Switched to ${backend.describe()}, but could not reach it: ${(err as Error).message}\n` +
`Start it with: ${startHint}`,
);
}
}
// Re-exported for potential future use (kept to avoid unused-import errors).
export { bytesToHex, neventEncode, naddrEncode };