1231 lines
42 KiB
TypeScript
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 };
|