-
-
-
-
- {{ $t("tutorial.documentation") }}
+
+
+
+
+
+
+
+ {{ $t("tutorial.documentation") }}
+
+
+ {{ $t("tutorial.documentation_desc_page") }}
+
+
+
-
- {{ $t("tutorial.documentation_desc_page") }}
-
-
+
+
+ {{ $t("tutorial.learn_create_more") }}
+
+
+
+
-
-
-
-
- {{ $t("tutorial.micron_editor") }}
-
-
- {{ $t("tutorial.micron_editor_desc_page") }}
-
-
-
-
-
-
-
-
-
-
-
- {{ $t("tutorial.paper_messages") }}
-
-
- {{ $t("tutorial.paper_messages_desc") }}
+
+
+
+
+ {{ $t("tutorial.send_messages") }}
+
+
+ {{ $t("tutorial.send_messages_desc") }}
+
-
-
-
-
-
- {{ $t("tutorial.send_messages") }}
-
-
- {{ $t("tutorial.send_messages_desc") }}
+
+
+
+
+ {{ $t("tutorial.explore_nodes") }}
+
+
+ {{ $t("tutorial.explore_nodes_desc") }}
+
-
-
-
-
-
- {{ $t("tutorial.explore_nodes") }}
-
-
- {{ $t("tutorial.explore_nodes_desc") }}
-
-
-
-
-
-
-
-
- {{ $t("tutorial.voice_calls") }}
-
-
- {{ $t("tutorial.voice_calls_desc") }}
+
+
+
+
+ {{ $t("tutorial.voice_calls") }}
+
+
+ {{ $t("tutorial.voice_calls_desc") }}
+
@@ -1632,6 +1912,8 @@ export default {
bootstrapListSearch: "",
bootstrapDiscoveredSectionOpen: true,
bootstrapCommunitySectionOpen: true,
+ bootstrapAutoPickDone: false,
+ pickingRandomBootstraps: false,
};
},
computed: {
@@ -1696,6 +1978,22 @@ export default {
reticulumBundledDocsUrl() {
return bundledReticulumDocsUrl(this.$i18n.locale);
},
+ bootstrapSelectedLabels() {
+ return this.selectedBootstrapKeys.map((k) => this.bootstrapDisplayLabelForKey(k)).filter(Boolean);
+ },
+ },
+ watch: {
+ communityInterfaces() {
+ this.$nextTick(() => void this.maybeAutoPickBootstrapTcp());
+ },
+ discoveredInterfaces() {
+ this.$nextTick(() => void this.maybeAutoPickBootstrapTcp());
+ },
+ currentStep(val) {
+ if (val === 3) {
+ this.$nextTick(() => void this.maybeAutoPickBootstrapTcp());
+ }
+ },
},
beforeUnmount() {
if (this.onWindowResize) {
@@ -1752,6 +2050,7 @@ export default {
this.bootstrapListSearch = "";
this.bootstrapDiscoveredSectionOpen = true;
this.bootstrapCommunitySectionOpen = true;
+ this.bootstrapAutoPickDone = false;
await this.loadDiscoveryBootstrapDefaults();
await this.loadCommunityInterfaces();
await this.loadDiscoveredInterfaces();
@@ -1834,6 +2133,9 @@ export default {
this.bootstrapListSearch = "";
this.bootstrapDiscoveredSectionOpen = true;
this.bootstrapCommunitySectionOpen = true;
+ await this.loadCommunityInterfaces();
+ await this.loadDiscoveredInterfaces();
+ await this.maybeAutoPickBootstrapTcp();
} catch (e) {
console.error("Failed to enable discovery:", e);
ToastUtils.error(this.$t("tutorial.failed_enable_discovery"));
@@ -1879,6 +2181,176 @@ export default {
this.selectedBootstrapKeys.push(key);
}
},
+ bootstrapDisplayLabelForKey(key) {
+ if (!key) {
+ return "";
+ }
+ if (key.startsWith("comm:")) {
+ const name = key.slice(5);
+ const iface = this.communityInterfaces.find((c) => c.name === name);
+ return iface?.name || name;
+ }
+ if (key.startsWith("disc:")) {
+ const suffix = key.slice(5);
+ const iface = this.discoveredInterfaces.find((d) => String(d.discovery_hash || d.name) === suffix);
+ return iface?.name || suffix;
+ }
+ return key;
+ },
+ communityBootstrapExcludedFromRandom(iface) {
+ const name = String(iface.name || "");
+ const desc = String(iface.description || "");
+ const host = String(iface.target_host || "").trim();
+ const hay = `${name} ${desc}`.toLowerCase();
+ if (hay.includes("yggdrasil")) {
+ return true;
+ }
+ if (/\bygg\b/.test(hay) || hay.includes("-ygg") || hay.includes(" ygg") || hay.includes("(ygg")) {
+ return true;
+ }
+ if (/^(200|201|202|203):[0-9a-f:]+$/i.test(host)) {
+ return true;
+ }
+ return false;
+ },
+ pickEligibleCommunityTcpBootstrapForRandom() {
+ const out = [];
+ for (const iface of this.communityInterfaces) {
+ const t = iface.type;
+ if (t !== "TCPClientInterface" && t !== "BackboneInterface") {
+ continue;
+ }
+ const host = String(iface.target_host || "").trim();
+ const port = iface.target_port;
+ if (!host || port === undefined || port === null || port === "") {
+ continue;
+ }
+ if (this.communityBootstrapExcludedFromRandom(iface)) {
+ continue;
+ }
+ out.push({
+ key: `comm:${iface.name}`,
+ kind: "community",
+ iface,
+ dedupe: `${host.toLowerCase()}:${Number(port)}`,
+ });
+ }
+ return out;
+ },
+ pickEligibleTcpBootstrapEntries() {
+ const out = [];
+ for (const iface of this.communityInterfaces) {
+ const t = iface.type;
+ if (t !== "TCPClientInterface" && t !== "BackboneInterface") {
+ continue;
+ }
+ const host = String(iface.target_host || "").trim();
+ const port = iface.target_port;
+ if (!host || port === undefined || port === null || port === "") {
+ continue;
+ }
+ out.push({
+ key: `comm:${iface.name}`,
+ kind: "community",
+ iface,
+ dedupe: `${host.toLowerCase()}:${Number(port)}`,
+ });
+ }
+ for (const iface of this.discoveredInterfaces) {
+ const host = String(iface.reachable_on || "").trim();
+ const port = iface.port;
+ if (!host || port === undefined || port === null || port === "") {
+ continue;
+ }
+ const typ = iface.type || "";
+ if (typ && typ !== "BackboneInterface" && typ !== "TCPClientInterface") {
+ continue;
+ }
+ out.push({
+ key: `disc:${iface.discovery_hash || iface.name}`,
+ kind: "discovered",
+ iface,
+ dedupe: `${host.toLowerCase()}:${Number(port)}`,
+ });
+ }
+ return out;
+ },
+ dedupeBootstrapEntries(entries) {
+ const seen = new Set();
+ const deduped = [];
+ for (const e of entries) {
+ if (seen.has(e.dedupe)) {
+ continue;
+ }
+ seen.add(e.dedupe);
+ deduped.push(e);
+ }
+ return deduped;
+ },
+ shuffleArrayInPlace(arr) {
+ for (let i = arr.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [arr[i], arr[j]] = [arr[j], arr[i]];
+ }
+ },
+ async pickRandomTcpBootstraps(options = {}) {
+ const silent = options.silent === true;
+ const auto = options.auto === true;
+ if (!silent && !auto) {
+ this.pickingRandomBootstraps = true;
+ }
+ await Promise.resolve();
+ await new Promise((resolve) => {
+ if (typeof requestAnimationFrame !== "undefined") {
+ requestAnimationFrame(() => resolve());
+ } else {
+ setTimeout(resolve, 0);
+ }
+ });
+ try {
+ let entries = this.pickEligibleCommunityTcpBootstrapForRandom();
+ entries = this.dedupeBootstrapEntries(entries);
+ if (entries.length === 0) {
+ if (!silent && !auto) {
+ ToastUtils.warning(this.$t("tutorial.bootstrap_pick_random_none"));
+ }
+ return;
+ }
+ this.shuffleArrayInPlace(entries);
+ const take = Math.min(3, entries.length);
+ this.selectedBootstrapKeys = entries.slice(0, take).map((e) => e.key);
+ const labels = this.selectedBootstrapKeys.map((k) => this.bootstrapDisplayLabelForKey(k));
+ if (!silent && !auto) {
+ ToastUtils.success(
+ this.$t("tutorial.bootstrap_pick_random_done", {
+ count: take,
+ names: labels.join(", "),
+ })
+ );
+ }
+ } finally {
+ if (!silent && !auto) {
+ this.pickingRandomBootstraps = false;
+ }
+ }
+ },
+ async maybeAutoPickBootstrapTcp() {
+ if (this.bootstrapAutoPickDone) {
+ return;
+ }
+ if (this.currentStep !== 3 || this.connectionMode !== "discovery") {
+ return;
+ }
+ if (this.selectedBootstrapKeys.length > 0) {
+ return;
+ }
+ const entries = this.dedupeBootstrapEntries(this.pickEligibleCommunityTcpBootstrapForRandom());
+ if (entries.length === 0) {
+ return;
+ }
+ await this.pickRandomTcpBootstraps({ silent: true, auto: true });
+ this.bootstrapAutoPickDone = true;
+ },
buildBootstrapPayload(item) {
if (item.kind === "discovered") {
const iface = item.iface;
diff --git a/meshchatx/src/frontend/components/docs/DocsPage.vue b/meshchatx/src/frontend/components/docs/DocsPage.vue
index b2eefb3..deb7be4 100644
--- a/meshchatx/src/frontend/components/docs/DocsPage.vue
+++ b/meshchatx/src/frontend/components/docs/DocsPage.vue
@@ -587,11 +587,20 @@ export default {
if (!this.status.has_docs) return [];
return this.allLanguages.filter((l) => l.code !== this.currentLang);
},
+ reticulumDocsQueryParam() {
+ return this.$route?.query?.reticulum;
+ },
+ },
+ watch: {
+ reticulumDocsQueryParam() {
+ this.applyDocumentationRouteQuery();
+ },
},
mounted() {
this.fetchStatus();
this.fetchMeshChatXDocs();
this.statusInterval = setInterval(this.fetchStatus, 2000);
+ this.applyDocumentationRouteQuery();
},
beforeUnmount() {
if (this.statusInterval) {
@@ -612,6 +621,9 @@ export default {
} catch (error) {
console.error("Failed to fetch docs status:", error);
}
+ if (this.reticulumDocsQueryParam) {
+ this.applyDocumentationRouteQuery();
+ }
},
dismissError() {
this.status = { ...this.status, last_error: null };
@@ -758,6 +770,28 @@ export default {
this.searchQuery = "";
this.searchResults = [];
},
+ applyDocumentationRouteQuery() {
+ const q = this.reticulumDocsQueryParam;
+ if (q === undefined || q === null || q === "") {
+ return;
+ }
+ const raw = Array.isArray(q) ? q[0] : q;
+ if (typeof raw !== "string" || !raw.trim()) {
+ return;
+ }
+ let path = raw.trim();
+ try {
+ path = decodeURIComponent(path);
+ } catch {
+ return;
+ }
+ path = path.replace(/^\/?(?:reticulum-docs\/)?/, "");
+ if (!path) {
+ return;
+ }
+ this.activeTab = "reticulum";
+ this.selectedReticulumPath = path;
+ },
navigateTo(path) {
if (path.startsWith("/meshchatx-docs/")) {
this.activeTab = "meshchatx";
diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
index 0da939a..7f9ddf4 100644
--- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
+++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
@@ -163,6 +163,9 @@
+
@@ -241,9 +244,9 @@
$t("interfaces.discovery_default_bootstrap_only")
}}
-
- {{ $t("interfaces.discovery_default_bootstrap_only_hint") }}
-
+
@@ -312,13 +315,20 @@
/>
-
Transport identity (hex)
+
{{
+ $t("interfaces.backbone_transport_identity_label")
+ }}
+
+ {{ $t("interfaces.backbone_transport_identity_hint") }}
+
-
- {{ $t("interfaces.discovery_default_bootstrap_only_hint") }}
-
+
@@ -524,12 +534,30 @@
>
-
-
Connect over network (IP)
+
+
+ Connect over network (IP)
+
+
+
+ {{ $t("interfaces.rnode_ble_toggle") }}
+
+
+
{{
+ $t("interfaces.rnode_ble_peer_label")
+ }}
+
+
+ {{ $t("interfaces.rnode_ble_hint") }}
+
+
Serial Port
@@ -1027,6 +1077,59 @@
/>
+
+
+
+
+ {{ $t("interfaces.loopback_local_title") }}
+
+
+ {{ $t("interfaces.loopback_local_body") }}
+
+
+
+
+
+
+
+ {{ $t("interfaces.custom_external_intro") }}
+
+
+ {{
+ $t("interfaces.custom_external_type_label")
+ }}
+
+
+
+ {{
+ $t("interfaces.custom_external_json_label")
+ }}
+
+
+
+
@@ -1232,9 +1335,7 @@
{{
$t("interfaces.discovery_default_bootstrap_only")
}}
-
- {{ $t("interfaces.discovery_default_bootstrap_only_hint") }}
-
+
@@ -1566,6 +1667,8 @@ import FormLabel from "../forms/FormLabel.vue";
import Toggle from "../forms/Toggle.vue";
import GlobalState from "../../js/GlobalState";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import BundledDocsHint from "./BundledDocsHint.vue";
+import { RETICULUM_MANUAL_INTERFACES_OVERVIEW_REL } from "../../js/reticulumDocsEntryUrl.js";
export default {
name: "AddInterfacePage",
@@ -1574,6 +1677,7 @@ export default {
FormLabel,
ExpandingSection,
Toggle,
+ BundledDocsHint,
},
data() {
return {
@@ -1582,6 +1686,10 @@ export default {
isSaving: false,
isEditingInterface: false,
+ customExternalTypeName: "",
+ customExternalOptionsJson: "{}",
+ docsReticulumInterfacesOverview: RETICULUM_MANUAL_INTERFACES_OVERVIEW_REL,
+
config: null,
communityInterfaces: [],
@@ -1673,6 +1781,8 @@ export default {
newInterfacePort: null,
newInterfaceRNodeUseIP: false,
+ newInterfaceRNodeUseBle: false,
+ newInterfaceRNodeBlePeer: "",
newInterfaceRNodeIPHost: "localhost",
newInterfaceRNodeIPPort: "7633",
RNodeGHzValue: 0,
@@ -1876,6 +1986,28 @@ export default {
console.log(e);
}
},
+ effectiveRNodeBlePort() {
+ let p = (this.newInterfaceRNodeBlePeer || "").trim();
+ if (!p) {
+ return "ble://";
+ }
+ if (p.toLowerCase().startsWith("ble://")) {
+ return p;
+ }
+ return `ble://${p}`;
+ },
+ setRNodeTransportIp(v) {
+ this.newInterfaceRNodeUseIP = Boolean(v);
+ if (this.newInterfaceRNodeUseIP) {
+ this.newInterfaceRNodeUseBle = false;
+ }
+ },
+ setRNodeTransportBle(v) {
+ this.newInterfaceRNodeUseBle = Boolean(v);
+ if (this.newInterfaceRNodeUseBle) {
+ this.newInterfaceRNodeUseIP = false;
+ }
+ },
async loadCommunityInterfaces() {
try {
const response = await window.api.get(`/api/v1/community-interfaces`);
@@ -1916,6 +2048,25 @@ export default {
this.newInterfaceName = interfaceName;
this.newInterfaceType = iface.type;
+ this.customExternalTypeName = "";
+ this.customExternalOptionsJson = "{}";
+ if (!this.isDedicatedFormInterfaceType(iface.type)) {
+ this.newInterfaceType = "__external__";
+ this.customExternalTypeName = iface.type;
+ const skip = new Set(["type", "name", "interface_enabled", "enabled"]);
+ const flat = {};
+ for (const [k, v] of Object.entries(iface)) {
+ if (skip.has(k)) {
+ continue;
+ }
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
+ continue;
+ }
+ flat[k] = v;
+ }
+ this.customExternalOptionsJson = JSON.stringify(flat, null, 2);
+ }
+
this.newInterfaceTargetHost = iface.target_host ?? iface.remote ?? null;
this.newInterfaceTargetPort = iface.target_port ?? null;
this.newInterfaceTransportIdentity = iface.transport_identity ?? null;
@@ -1982,7 +2133,12 @@ export default {
this.newInterfacePort = iface.port;
this.newInterfaceRNodeUseIP = false;
- if (iface.port && String(iface.port).startsWith("tcp://")) {
+ this.newInterfaceRNodeUseBle = false;
+ this.newInterfaceRNodeBlePeer = "";
+ if (iface.port && String(iface.port).toLowerCase().startsWith("ble://")) {
+ this.newInterfaceRNodeUseBle = true;
+ this.newInterfaceRNodeBlePeer = String(iface.port);
+ } else if (iface.port && String(iface.port).startsWith("tcp://")) {
const addr = String(iface.port).replace("tcp://", "");
const parts = addr.split(":");
this.newInterfaceRNodeIPHost = parts[0] || "localhost";
@@ -2103,7 +2259,12 @@ export default {
if (config.forward_port) this.newInterfaceForwardPort = Number(config.forward_port);
if (config.port) {
this.newInterfacePort = config.port;
- if (config.port.startsWith("tcp://")) {
+ this.newInterfaceRNodeUseBle = false;
+ this.newInterfaceRNodeUseIP = false;
+ if (String(config.port).toLowerCase().startsWith("ble://")) {
+ this.newInterfaceRNodeUseBle = true;
+ this.newInterfaceRNodeBlePeer = config.port;
+ } else if (config.port.startsWith("tcp://")) {
const addr = config.port.replace("tcp://", "");
const [host, port] = addr.split(":");
this.newInterfaceRNodeIPHost = host;
@@ -2391,6 +2552,73 @@ export default {
const discoveryEnabled = this.discovery.discoverable === true;
const freqHz = Math.round(this.calculateFrequencyInHz());
+ if (this.newInterfaceType === "RNodeInterface" && this.newInterfaceRNodeUseBle) {
+ const raw = (this.newInterfaceRNodeBlePeer || "").trim();
+ const inner = raw.toLowerCase().startsWith("ble://") ? raw.slice(6).trim() : raw;
+ if (!inner) {
+ ToastUtils.warning(this.$t("interfaces.rnode_ble_peer_required"));
+ return;
+ }
+ }
+
+ if (this.newInterfaceType === "__external__") {
+ const typeStr = (this.customExternalTypeName || "").trim();
+ if (!typeStr) {
+ ToastUtils.error(this.$t("interfaces.custom_external_type_required"));
+ return;
+ }
+ let extra = {};
+ try {
+ extra = JSON.parse((this.customExternalOptionsJson || "").trim() || "{}");
+ } catch {
+ ToastUtils.error(this.$t("interfaces.custom_external_json_invalid"));
+ return;
+ }
+ if (extra !== null && typeof extra !== "object") {
+ ToastUtils.error(this.$t("interfaces.custom_external_json_invalid"));
+ return;
+ }
+ const payload = {
+ allow_overwriting_interface: this.isEditingInterface,
+ name: this.newInterfaceName,
+ type: typeStr,
+ extra_config: extra,
+ discoverable: discoveryEnabled ? "yes" : null,
+ discovery_name: discoveryEnabled ? this.discovery.discovery_name : null,
+ announce_interval: discoveryEnabled
+ ? (this.numOrNull(this.discovery.announce_interval) ?? 360)
+ : null,
+ reachable_on: discoveryEnabled ? this.discovery.reachable_on : null,
+ discovery_stamp_value: discoveryEnabled
+ ? (this.numOrNull(this.discovery.discovery_stamp_value) ?? 14)
+ : null,
+ discovery_encrypt: discoveryEnabled ? this.discovery.discovery_encrypt === true : null,
+ publish_ifac: discoveryEnabled ? this.discovery.publish_ifac === true : null,
+ latitude: discoveryEnabled ? this.numOrNull(this.discovery.latitude) : null,
+ longitude: discoveryEnabled ? this.numOrNull(this.discovery.longitude) : null,
+ height: discoveryEnabled ? this.numOrNull(this.discovery.height) : null,
+ discovery_frequency: discoveryEnabled
+ ? this.numOrNull(this.discovery.discovery_frequency)
+ : null,
+ discovery_bandwidth: discoveryEnabled
+ ? this.numOrNull(this.discovery.discovery_bandwidth)
+ : null,
+ discovery_modulation: discoveryEnabled
+ ? this.numOrNull(this.discovery.discovery_modulation)
+ : null,
+ mode: this.sharedInterfaceSettings.mode || null,
+ bitrate: this.sharedInterfaceSettings.bitrate,
+ network_name: this.sharedInterfaceSettings.network_name,
+ passphrase: this.sharedInterfaceSettings.passphrase,
+ };
+ const response = await window.api.post(`/api/v1/reticulum/interfaces/add`, payload);
+ if (response.data.message) ToastUtils.success(response.data.message);
+ GlobalState.hasPendingInterfaceChanges = true;
+ GlobalState.modifiedInterfaceNames.add(this.newInterfaceName);
+ this.$router.push({ name: "interfaces" });
+ return;
+ }
+
const i2pPeers =
this.newInterfaceType === "I2PInterface"
? (this.I2PSettings.newInterfacePeers || []).map((p) => String(p).trim()).filter(Boolean)
@@ -2433,7 +2661,9 @@ export default {
this.newInterfaceType === "I2PInterface" ? this.newInterfaceConnectable === true : null,
port: this.newInterfaceRNodeUseIP
? `tcp://${this.newInterfaceRNodeIPHost}:${this.newInterfaceRNodeIPPort}`
- : this.newInterfacePort,
+ : this.newInterfaceRNodeUseBle
+ ? this.effectiveRNodeBlePort()
+ : this.newInterfacePort,
frequency: freqHz,
bandwidth: this.newInterfaceBandwidth,
txpower: this.newInterfaceTxpower,
@@ -2555,6 +2785,25 @@ export default {
removeSubInterface(idx) {
this.RNodeMultiInterface.subInterfaces.splice(idx, 1);
},
+ isDedicatedFormInterfaceType(t) {
+ const builtin = new Set([
+ "TCPClientInterface",
+ "BackboneInterface",
+ "I2PInterface",
+ "TCPServerInterface",
+ "UDPInterface",
+ "RNodeInterface",
+ "RNodeIPInterface",
+ "RNodeMultiInterface",
+ "SerialInterface",
+ "KISSInterface",
+ "AX25KISSInterface",
+ "PipeInterface",
+ "AutoInterface",
+ "LocalInterface",
+ ]);
+ return builtin.has(t);
+ },
},
};
diff --git a/meshchatx/src/frontend/components/interfaces/BundledDocsHint.vue b/meshchatx/src/frontend/components/interfaces/BundledDocsHint.vue
new file mode 100644
index 0000000..a9a3de5
--- /dev/null
+++ b/meshchatx/src/frontend/components/interfaces/BundledDocsHint.vue
@@ -0,0 +1,47 @@
+
+
+
+
+ {{ $t(hintI18nKey) }}
+ {{ " " }}
+
+
+
+
+
diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
index f8ccb48..b9790e0 100644
--- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
+++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
@@ -656,7 +656,9 @@
{{ $t("interfaces.discovery_default_bootstrap_only") }}
- {{ $t("interfaces.discovery_default_bootstrap_only_hint") }}
+