FN-8202: prevent mDNS hostname conflicts
Prevent self-conflicting mDNS advertisements and allow automatic LAN discovery to be disabled. - Advertise a Fusion-owned DNS-SD hostname derived from the node ID. - Add a global automatic LAN discovery opt-out for dashboard and serve. - Cover hostname isolation and discovery startup behavior with tests and documentation. Files changed: .changeset/fn-8202-mdns-hostname-fix.md | 7 +++++ docs/settings-reference.md | 1 + docs/shared-mesh-protocol.md | 2 +- .../cli/src/commands/__tests__/dashboard.test.ts | 24 ++++++++++++++++ packages/cli/src/commands/__tests__/serve.test.ts | 12 ++++++++ packages/cli/src/commands/dashboard.ts | 24 +++++++++++----- packages/cli/src/commands/serve.ts | 24 +++++++++++----- packages/core/src/__tests__/node-discovery.test.ts | 32 +++++++++++++++++++++- .../core/src/__tests__/settings-defaults.test.ts | 8 ++++++ packages/core/src/node-discovery.ts | 31 +++++++++++++++++++++ packages/core/src/settings-schema.ts | 5 ++++ packages/core/src/types.ts | 2 ++ 12 files changed, 156 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-8202 Fusion-Task-Lineage: c1d6e36b-bda7-4968-b40c-c14e16ebda28 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8202-mdns-hostname-fix.md
Normal file
7
.changeset/fn-8202-mdns-hostname-fix.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Stop fn dashboard from making macOS rename its own local hostname over mDNS.
|
||||||
|
category: fix
|
||||||
|
dev: node-discovery now advertises a Fusion-owned mDNS host (fusion-<nodeId8>) instead of os.hostname(), avoiding the self-conflict rename; adds global setting `localNetworkDiscoveryEnabled` (default true) to disable LAN auto-discovery in `fn dashboard`/`fn serve`.
|
||||||
@@ -107,6 +107,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio
|
|||||||
| `gitlabAuthToken` | `string` | `undefined` | Global fallback GitLab access token used by later HTTP API integrations when the project does not set its own token. The dashboard renders this as a password input and never displays saved token values in helper text. The resolver trims whitespace and falls back to process `GITLAB_TOKEN` only when both project and global tokens are blank. |
|
| `gitlabAuthToken` | `string` | `undefined` | Global fallback GitLab access token used by later HTTP API integrations when the project does not set its own token. The dashboard renders this as a password input and never displays saved token values in helper text. The resolver trims whitespace and falls back to process `GITLAB_TOKEN` only when both project and global tokens are blank. |
|
||||||
| `gitlabAuthTokenType` | `"personal" \| "project" \| "group"` | `undefined` (effective `"personal"` when a token exists) | Global fallback GitLab token family label for operator clarity. Project tokens and group tokens remain limited to their associated project/group and role membership; this label does not expand authorization. Unsupported values are rejected by the GitLab auth resolver. |
|
| `gitlabAuthTokenType` | `"personal" \| "project" \| "group"` | `undefined` (effective `"personal"` when a token exists) | Global fallback GitLab token family label for operator clarity. Project tokens and group tokens remain limited to their associated project/group and role membership; this label does not expand authorization. Unsupported values are rejected by the GitLab auth resolver. |
|
||||||
| `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (default), the dashboard automatically reloads when a new build version is detected via `/version.json` polling or service worker activation. Set to `false` to suppress automatic reloads — the user must manually refresh to pick up updates. |
|
| `autoReloadOnVersionChange` | `boolean` | `true` | When enabled (default), the dashboard automatically reloads when a new build version is detected via `/version.json` polling or service worker activation. Set to `false` to suppress automatic reloads — the user must manually refresh to pick up updates. |
|
||||||
|
| `localNetworkDiscoveryEnabled` | `boolean` | `true` | Enables automatic `_fusion._tcp` LAN broadcast and listening when `fn dashboard` or `fn serve` starts. Set `false` to opt out of automatic mDNS/DNS-SD discovery; an explicit operator `POST /api/discovery/start` request remains available. |
|
||||||
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
|
| `modelOnboardingComplete` | `boolean` | `undefined` | Whether AI onboarding has been completed or dismissed. |
|
||||||
| `useCursorCli` | `boolean` | `undefined` | Enables the `cursor-cli` provider in model pickers after Cursor CLI status validation. Toggle from Settings → Authentication. This runtime auth is OAuth/session-based; Cursor usage metering is separate and reads a Cursor Admin API key from the dashboard process `CURSOR_API_KEY` env var. |
|
| `useCursorCli` | `boolean` | `undefined` | Enables the `cursor-cli` provider in model pickers after Cursor CLI status validation. Toggle from Settings → Authentication. This runtime auth is OAuth/session-based; Cursor usage metering is separate and reads a Cursor Admin API key from the dashboard process `CURSOR_API_KEY` env var. |
|
||||||
| `cursorCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Cursor CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `cursor-agent` then `cursor` on PATH. Use this when PATH points at the wrong Cursor install or Windows exposes a specific `.cmd`/`.bat` shim; invalid non-empty saves are rejected with bounded diagnostics. |
|
| `cursorCliBinaryPath` | `string` | `undefined` | Optional global, machine-local Cursor CLI executable override used by Settings → Authentication, status/enable validation, probes, and model discovery. Leave unset/blank to auto-detect `cursor-agent` then `cursor` on PATH. Use this when PATH points at the wrong Cursor install or Windows exposes a specific `.cmd`/`.bat` shim; invalid non-empty saves are rejected with bounded diagnostics. |
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ Still useful under shared Postgres:
|
|||||||
| `POST /api/mesh/sync` | Peer gossip: `knownPeers` (+ optional `authMaterial` only) |
|
| `POST /api/mesh/sync` | Peer gossip: `knownPeers` (+ optional `authMaterial` only) |
|
||||||
| `POST/GET /api/mesh/task-ids/*` | Local allocator against shared ID tables (no remote coordinator hop) |
|
| `POST/GET /api/mesh/task-ids/*` | Local allocator against shared ID tables (no remote coordinator hop) |
|
||||||
| Auth sync routes | Optional credential fan-out for file-local auth |
|
| Auth sync routes | Optional credential fan-out for file-local auth |
|
||||||
| mDNS discovery | Join convenience, not task SoT |
|
| mDNS discovery | Join convenience, not task SoT. `_fusion._tcp` advertises a Fusion-owned `fusion-<nodeId8>` host rather than the OS hostname; set global `localNetworkDiscoveryEnabled: false` to disable dashboard/serve auto-start. |
|
||||||
| Docker mesh config generator | Provision managed peers |
|
| Docker mesh config generator | Provision managed peers |
|
||||||
|
|
||||||
Removed / disabled:
|
Removed / disabled:
|
||||||
|
|||||||
@@ -3002,6 +3002,30 @@ describe("runDashboard — mesh lifecycle ownership", () => {
|
|||||||
dispose();
|
dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips automatic discovery when local network discovery is disabled", async () => {
|
||||||
|
const { CentralCore } = await import("@fusion/core");
|
||||||
|
const startDiscovery = vi.fn().mockResolvedValue(undefined);
|
||||||
|
mockGlobalSettingsGetSettings.mockResolvedValue({ localNetworkDiscoveryEnabled: false });
|
||||||
|
|
||||||
|
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||||
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
|
||||||
|
listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]),
|
||||||
|
listNodes: vi.fn().mockResolvedValue([{ id: "node-local", type: "local", status: "offline" }]),
|
||||||
|
updateNode: vi.fn().mockResolvedValue(undefined),
|
||||||
|
startDiscovery,
|
||||||
|
stopDiscovery: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { dispose } = await runDashboard(0, { open: false });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(startDiscovery).not.toHaveBeenCalled();
|
||||||
|
dispose();
|
||||||
|
});
|
||||||
|
|
||||||
it("stops peer exchange and discovery during shutdown", async () => {
|
it("stops peer exchange and discovery during shutdown", async () => {
|
||||||
const { CentralCore } = await import("@fusion/core");
|
const { CentralCore } = await import("@fusion/core");
|
||||||
const { PeerExchangeService } = await import("@fusion/engine");
|
const { PeerExchangeService } = await import("@fusion/engine");
|
||||||
|
|||||||
@@ -1786,6 +1786,18 @@ describe("runServe — Peer exchange and discovery", () => {
|
|||||||
await triggerSignal("SIGINT");
|
await triggerSignal("SIGINT");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips automatic discovery when local network discovery is disabled", async () => {
|
||||||
|
mocks.globalSettingsGetSettings.mockResolvedValue({ localNetworkDiscoveryEnabled: false });
|
||||||
|
|
||||||
|
await runServe(4040, {});
|
||||||
|
|
||||||
|
const nodeCentral = mocks.centralInstances.find((instance) => instance.listNodes.mock.calls.length > 0);
|
||||||
|
expect(nodeCentral).toBeDefined();
|
||||||
|
expect(nodeCentral.startDiscovery).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await triggerSignal("SIGINT");
|
||||||
|
});
|
||||||
|
|
||||||
it("starts discovery with port 5050 when port 0 is requested", async () => {
|
it("starts discovery with port 5050 when port 0 is requested", async () => {
|
||||||
await runServe(0, {});
|
await runServe(0, {});
|
||||||
|
|
||||||
|
|||||||
@@ -2738,13 +2738,23 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
//
|
//
|
||||||
if (centralCoreForMesh) {
|
if (centralCoreForMesh) {
|
||||||
try {
|
try {
|
||||||
await centralCoreForMesh.startDiscovery({
|
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||||
broadcast: true,
|
/*
|
||||||
listen: true,
|
* FNXC:NodeDiscovery 2026-07-17-12:00:
|
||||||
serviceType: "_fusion._tcp",
|
* FN-8202 requires dashboard boot to respect the global LAN discovery
|
||||||
port: actualPort,
|
* opt-out. The explicit discovery API remains an operator override.
|
||||||
staleTimeoutMs: 300_000,
|
*/
|
||||||
});
|
if (globalSettings.localNetworkDiscoveryEnabled === false) {
|
||||||
|
logSink.warn("LAN discovery disabled by localNetworkDiscoveryEnabled setting", "dashboard");
|
||||||
|
} else {
|
||||||
|
await centralCoreForMesh.startDiscovery({
|
||||||
|
broadcast: true,
|
||||||
|
listen: true,
|
||||||
|
serviceType: "_fusion._tcp",
|
||||||
|
port: actualPort,
|
||||||
|
staleTimeoutMs: 300_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
logSink.warn(`Failed to start mDNS discovery: ${message}`, "dashboard");
|
logSink.warn(`Failed to start mDNS discovery: ${message}`, "dashboard");
|
||||||
|
|||||||
@@ -1075,13 +1075,23 @@ export async function runServe(
|
|||||||
//
|
//
|
||||||
if (sharedCentralCore) {
|
if (sharedCentralCore) {
|
||||||
try {
|
try {
|
||||||
await sharedCentralCore.startDiscovery({
|
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||||
broadcast: true,
|
/*
|
||||||
listen: true,
|
* FNXC:NodeDiscovery 2026-07-17-12:00:
|
||||||
serviceType: "_fusion._tcp",
|
* FN-8202 makes automatic LAN discovery opt-outable at daemon boot;
|
||||||
port: actualPort,
|
* explicit POST /api/discovery/start requests intentionally still work.
|
||||||
staleTimeoutMs: 300_000,
|
*/
|
||||||
});
|
if (globalSettings.localNetworkDiscoveryEnabled === false) {
|
||||||
|
console.warn("[serve] LAN discovery disabled by localNetworkDiscoveryEnabled setting");
|
||||||
|
} else {
|
||||||
|
await sharedCentralCore.startDiscovery({
|
||||||
|
broadcast: true,
|
||||||
|
listen: true,
|
||||||
|
serviceType: "_fusion._tcp",
|
||||||
|
port: actualPort,
|
||||||
|
staleTimeoutMs: 300_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
console.warn(`[serve] Failed to start mDNS discovery: ${message}`);
|
console.warn(`[serve] Failed to start mDNS discovery: ${message}`);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
import os from "node:os";
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import type { DiscoveryConfig, DiscoveredNode } from "../types.js";
|
import type { DiscoveryConfig, DiscoveredNode } from "../types.js";
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ describe("NodeDiscovery", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts and stops broadcast mode", () => {
|
it("starts and stops broadcast mode", () => {
|
||||||
@@ -128,11 +130,39 @@ describe("NodeDiscovery", () => {
|
|||||||
|
|
||||||
expect(publishMock).toHaveBeenCalledWith(
|
expect(publishMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: expect.stringMatching(/-_local_1$/),
|
name: expect.stringMatching(/-_local_1$/),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[" ", "node_local_1", "fusion-delocal1"],
|
||||||
|
["A very long local node name that is not used as the mDNS host", "node_abcdefghijk", "fusion-defghijk"],
|
||||||
|
["Local", "id", "fusion-id"],
|
||||||
|
])("publishes a Fusion-owned host for nodeName %j and nodeId %j", (nodeName, nodeId, expectedHost) => {
|
||||||
|
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true }));
|
||||||
|
discovery.start(nodeId, nodeName);
|
||||||
|
|
||||||
|
const config = publishMock.mock.calls[0]?.[0] as { host?: string };
|
||||||
|
expect(config.host).toBe(expectedHost);
|
||||||
|
expect(config.host).toMatch(/^fusion-[a-z0-9-]+$/);
|
||||||
|
expect(config.host?.replace(/\.local\.?$/i, "").toLowerCase()).not.toBe(
|
||||||
|
os.hostname().replace(/\.local\.?$/i, "").toLowerCase(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never publishes the OS hostname when it matches the naïve Fusion host", () => {
|
||||||
|
vi.spyOn(os, "hostname").mockReturnValue("fusion-delocal1.local");
|
||||||
|
const discovery = new NodeDiscovery(defaultConfig({ broadcast: true }));
|
||||||
|
discovery.start("node_local_1", "Local");
|
||||||
|
|
||||||
|
const config = publishMock.mock.calls[0]?.[0] as { host?: string };
|
||||||
|
expect(config.host).toBe("fusion-delocal1-service");
|
||||||
|
expect(config.host?.replace(/\.local\.?$/i, "").toLowerCase()).not.toBe(
|
||||||
|
os.hostname().replace(/\.local\.?$/i, "").toLowerCase(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("starts listen mode and emits node:discovered/node:lost", () => {
|
it("starts listen mode and emits node:discovered/node:lost", () => {
|
||||||
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
|
const discovery = new NodeDiscovery(defaultConfig({ listen: true }));
|
||||||
const discoveredHandler = vi.fn();
|
const discoveredHandler = vi.fn();
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ describe("settings defaults invariants", () => {
|
|||||||
expect(DEFAULT_PROJECT_SETTINGS.worktreesDir).toBeUndefined();
|
expect(DEFAULT_PROJECT_SETTINGS.worktreesDir).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults local network discovery on and keeps its opt-out global-only", () => {
|
||||||
|
expect(DEFAULT_GLOBAL_SETTINGS.localNetworkDiscoveryEnabled).toBe(true);
|
||||||
|
expect(GLOBAL_SETTINGS_KEYS).toContain("localNetworkDiscoveryEnabled");
|
||||||
|
expect(PROJECT_SETTINGS_KEYS).not.toContain("localNetworkDiscoveryEnabled");
|
||||||
|
expect("localNetworkDiscoveryEnabled" in DEFAULT_PROJECT_SETTINGS).toBe(false);
|
||||||
|
expect(isGlobalOnlySettingsKey("localNetworkDiscoveryEnabled")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults dashboard keyboard shortcuts globally", () => {
|
it("defaults dashboard keyboard shortcuts globally", () => {
|
||||||
expect(DEFAULT_GLOBAL_SETTINGS.dashboardKeyboardShortcuts).toEqual({
|
expect(DEFAULT_GLOBAL_SETTINGS.dashboardKeyboardShortcuts).toEqual({
|
||||||
quickChat: "Space",
|
quickChat: "Space",
|
||||||
|
|||||||
@@ -18,6 +18,28 @@ const DEFAULT_DISCOVERY_CONFIG: DiscoveryConfig = {
|
|||||||
const STALE_CLEANUP_INTERVAL_MS = 60_000;
|
const STALE_CLEANUP_INTERVAL_MS = 60_000;
|
||||||
const FUSION_VERSION = "0.1.0";
|
const FUSION_VERSION = "0.1.0";
|
||||||
|
|
||||||
|
function normalizeMdnsHost(host: string): string {
|
||||||
|
return host.trim().replace(/\.local\.?$/i, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a bounded, DNS-label-safe hostname owned by Fusion rather than the OS.
|
||||||
|
*/
|
||||||
|
function deriveFusionMdnsHost(nodeId: string): string {
|
||||||
|
const suffix = nodeId
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]/g, "")
|
||||||
|
.slice(-8)
|
||||||
|
.replace(/^-+|-+$/g, "") || "node";
|
||||||
|
const host = `fusion-${suffix}`;
|
||||||
|
|
||||||
|
// An unusually named host can equal the naïve Fusion label; retain ownership
|
||||||
|
// while guaranteeing that Fusion never claims the OS hostname.
|
||||||
|
return normalizeMdnsHost(host) === normalizeMdnsHost(os.hostname())
|
||||||
|
? `${host}-service`
|
||||||
|
: host;
|
||||||
|
}
|
||||||
|
|
||||||
interface NodeDiscoveryEvents {
|
interface NodeDiscoveryEvents {
|
||||||
"node:discovered": [node: DiscoveredNode];
|
"node:discovered": [node: DiscoveredNode];
|
||||||
"node:updated": [node: DiscoveredNode];
|
"node:updated": [node: DiscoveredNode];
|
||||||
@@ -111,8 +133,16 @@ export class NodeDiscovery extends EventEmitter<NodeDiscoveryEvents> {
|
|||||||
|
|
||||||
const bonjour = this.getBonjour();
|
const bonjour = this.getBonjour();
|
||||||
const serviceType = this.parseServiceType(this.config.serviceType);
|
const serviceType = this.parseServiceType(this.config.serviceType);
|
||||||
|
const host = deriveFusionMdnsHost(nodeId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
/*
|
||||||
|
* FNXC:NodeDiscovery 2026-07-17-12:00:
|
||||||
|
* bonjour-service probes the DNS-SD instance FQDN but not its advertised
|
||||||
|
* A/SRV host. FN-8202 found that re-announcing the OS hostname made macOS
|
||||||
|
* mDNSResponder self-conflict and rename the host, so publish a
|
||||||
|
* Fusion-owned target instead of os.hostname().
|
||||||
|
*/
|
||||||
this.broadcastService = bonjour.publish({
|
this.broadcastService = bonjour.publish({
|
||||||
/*
|
/*
|
||||||
* FNXC:NodeDiscovery 2026-07-15-18:05:
|
* FNXC:NodeDiscovery 2026-07-15-18:05:
|
||||||
@@ -122,6 +152,7 @@ export class NodeDiscovery extends EventEmitter<NodeDiscoveryEvents> {
|
|||||||
* optional mDNS collision from disrupting the dashboard process.
|
* optional mDNS collision from disrupting the dashboard process.
|
||||||
*/
|
*/
|
||||||
name: `${nodeName.trim() || os.hostname()}-${nodeId.slice(-8)}`,
|
name: `${nodeName.trim() || os.hostname()}-${nodeId.slice(-8)}`,
|
||||||
|
host,
|
||||||
type: serviceType.type,
|
type: serviceType.type,
|
||||||
protocol: serviceType.protocol,
|
protocol: serviceType.protocol,
|
||||||
port: this.config.port,
|
port: this.config.port,
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
|||||||
autoBackupDir: ".fusion/backups",
|
autoBackupDir: ".fusion/backups",
|
||||||
backupSettingsMigrationConflicts: undefined,
|
backupSettingsMigrationConflicts: undefined,
|
||||||
/*
|
/*
|
||||||
|
FNXC:NodeDiscovery 2026-07-17-12:00:
|
||||||
|
LAN discovery remains automatic for existing operators, while FN-8202 provides a global opt-out from dashboard and serve mDNS/DNS-SD auto-start.
|
||||||
|
*/
|
||||||
|
localNetworkDiscoveryEnabled: true,
|
||||||
|
/*
|
||||||
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
FNXC:DashboardShortcuts 2026-07-04-00:00:
|
||||||
Global dashboard shortcuts must hydrate with documented safe defaults even when old settings files are missing the object. Space opens Quick Chat; Ctrl+` opens Terminal without colliding with common browser find/search accelerators. FN-7553 adds openFiles (Ctrl+E), openSettings (Ctrl+,), openCommandCenter (Ctrl+K), and newTask (Ctrl+Shift+N) — chosen to avoid colliding with the base two or each other. Empty strings are preserved so operators can disable an action.
|
Global dashboard shortcuts must hydrate with documented safe defaults even when old settings files are missing the object. Space opens Quick Chat; Ctrl+` opens Terminal without colliding with common browser find/search accelerators. FN-7553 adds openFiles (Ctrl+E), openSettings (Ctrl+,), openCommandCenter (Ctrl+K), and newTask (Ctrl+Shift+N) — chosen to avoid colliding with the base two or each other. Empty strings are preserved so operators can disable an action.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2454,6 +2454,8 @@ export interface GlobalSettings {
|
|||||||
autoBackupDir?: string;
|
autoBackupDir?: string;
|
||||||
/** Durable candidates requiring an operator choice after project-to-global backup migration. */
|
/** Durable candidates requiring an operator choice after project-to-global backup migration. */
|
||||||
backupSettingsMigrationConflicts?: BackupSettingsMigrationConflict[];
|
backupSettingsMigrationConflicts?: BackupSettingsMigrationConflict[];
|
||||||
|
/** When false, fn dashboard and fn serve skip automatic mDNS/DNS-SD LAN discovery. Default: true (FN-8202 opt-out). */
|
||||||
|
localNetworkDiscoveryEnabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* FNXC:DashboardShortcuts 2026-07-04-00:00:
|
* FNXC:DashboardShortcuts 2026-07-04-00:00:
|
||||||
* Dashboard keyboard shortcuts are global operator preferences because they control browser UI affordances, not project execution policy. Defaults keep Space for Quick Chat and Ctrl+` for Terminal; blank values intentionally disable an action.
|
* Dashboard keyboard shortcuts are global operator preferences because they control browser UI affordances, not project execution policy. Defaults keep Space for Quick Chat and Ctrl+` for Terminal; blank values intentionally disable an action.
|
||||||
|
|||||||
Reference in New Issue
Block a user