{
- setImportDialogInput(e.target.value);
- }}
+ value={input}
+ variant={input === "" ? "default" : share ? "dirty" : "invalid"}
+ onChange={(event) => parse(event.target.value)}
/>
- {validUrl && (
-
-
-
- setUpdateConfig(next)}
- />
-
-
-
-
+ {share && plan && (
+
+
+
+ {t(
+ plan.mode === "replace"
+ ? "import.replaceDescription"
+ : "import.addDescription",
+ )}
+
+ {editorIsDirty && (
+
+ {t("import.pendingChanges")}
+
+ )}
+ {share.addOnly && (
+
+ {t("import.addOnly")}
+
+ )}
+
+ {t(
+ plan.applyLora
+ ? "import.loraWillApply"
+ : "import.loraWillNotApply",
+ )}
+
+ {plan.duplicateNames.length > 0 && (
+
+ {t("import.duplicateNames", {
+ names: plan.duplicateNames.join(", "),
+ })}
+
+ )}
+ {plan.capacityShortfall > 0 && (
+
+ {t("import.capacity", {
+ available: plan.availableSlots.length,
+ needed: share.channelSet.settings.length,
+ })}
+
+ )}
{t("import.channelName")}
{t("import.channelSlot")}
- {channelSet?.settings.map((channel, index) => (
-
-
-
+ );
+ })}
)}
-
@@ -226,3 +268,34 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
);
};
+
+const ModePicker = ({
+ addOnly,
+ mode,
+ onChange,
+}: {
+ addOnly: boolean;
+ mode: ChannelShareMode;
+ onChange: (mode: ChannelShareMode) => void;
+}) => {
+ const { t } = useTranslation("dialog");
+ return (
+
+ );
+};
diff --git a/apps/web/src/components/Dialog/QRDialog.tsx b/apps/web/src/components/Dialog/QRDialog.tsx
index 7220604ba..8840cd8cf 100644
--- a/apps/web/src/components/Dialog/QRDialog.tsx
+++ b/apps/web/src/components/Dialog/QRDialog.tsx
@@ -1,4 +1,3 @@
-import { create, toBinary } from "@bufbuild/protobuf";
import {
Dialog,
DialogClose,
@@ -8,12 +7,18 @@ import {
DialogHeader,
DialogTitle,
} from "@components/UI/Dialog.tsx";
+import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { Label } from "@components/UI/Label.tsx";
+import { useCopyToClipboard } from "@core/hooks/useCopyToClipboard.ts";
+import {
+ encodeChannelShare,
+ type ChannelShareMode,
+} from "@core/utils/channelShare.ts";
import { Protobuf } from "@meshtastic/sdk";
import { useChannels } from "@meshtastic/sdk-react";
-import { fromByteArray } from "base64-js";
-import { useEffect, useState } from "react";
+import { Share2 } from "lucide-react";
+import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { QRCode } from "react-qrcode-logo";
import { Checkbox } from "../UI/Checkbox/index.tsx";
@@ -26,35 +31,46 @@ export interface QRDialogProps {
export const QRDialog = ({ open, onOpenChange, loraConfig }: QRDialogProps) => {
const { t } = useTranslation("dialog");
+ const { copy } = useCopyToClipboard();
const [selectedChannels, setSelectedChannels] = useState
([0]);
- const [qrCodeUrl, setQrCodeUrl] = useState("");
- const [qrCodeAdd, setQrCodeAdd] = useState();
-
+ const [mode, setMode] = useState("replace");
const allChannels = useChannels();
- useEffect(() => {
- const channelsToEncode = allChannels
- .filter((ch) => selectedChannels.includes(ch.index))
+ const qrCodeUrl = useMemo(() => {
+ const settings = allChannels
+ .filter((channel) => selectedChannels.includes(channel.index))
.map((channel) => channel.settings)
- .filter((ch): ch is Protobuf.Channel.ChannelSettings => !!ch);
- const encoded = create(
- Protobuf.AppOnly.ChannelSetSchema,
- create(Protobuf.AppOnly.ChannelSetSchema, {
- loraConfig,
- settings: channelsToEncode,
- }),
- );
- const base64 = fromByteArray(
- toBinary(Protobuf.AppOnly.ChannelSetSchema, encoded),
- )
- .replace(/=/g, "")
- .replace(/\+/g, "-")
- .replace(/\//g, "_");
+ .filter(
+ (channel): channel is Protobuf.Channel.ChannelSettings => !!channel,
+ );
+
+ return settings.length > 0
+ ? encodeChannelShare({ mode, settings, loraConfig })
+ : "";
+ }, [allChannels, loraConfig, mode, selectedChannels]);
+
+ const share = async () => {
+ if (!qrCodeUrl) return;
+ if (
+ typeof navigator !== "undefined" &&
+ typeof navigator.share === "function"
+ ) {
+ try {
+ await navigator.share({
+ title: t("qr.title"),
+ text: t("qr.shareText"),
+ url: qrCodeUrl,
+ });
+ return;
+ } catch (error) {
+ if (error instanceof DOMException && error.name === "AbortError")
+ return;
+ }
+ }
+ await copy(qrCodeUrl);
+ };
- setQrCodeUrl(
- `https://meshtastic.org/e/${qrCodeAdd ? "?add=true" : ""}#${base64}`,
- );
- }, [allChannels, selectedChannels, qrCodeAdd, loraConfig]);
+ const selectMode = (nextMode: ChannelShareMode) => setMode(nextMode);
return (
);
};
+
+const ModePicker = ({
+ mode,
+ onChange,
+}: {
+ mode: ChannelShareMode;
+ onChange: (mode: ChannelShareMode) => void;
+}) => {
+ const { t } = useTranslation("dialog");
+ return (
+
+ );
+};
diff --git a/apps/web/src/core/utils/channelShare.test.ts b/apps/web/src/core/utils/channelShare.test.ts
new file mode 100644
index 000000000..292f0e049
--- /dev/null
+++ b/apps/web/src/core/utils/channelShare.test.ts
@@ -0,0 +1,274 @@
+import { create } from "@bufbuild/protobuf";
+import { Protobuf } from "@meshtastic/sdk";
+import { describe, expect, it } from "vitest";
+import {
+ applyChannelImport,
+ createChannelImportPlan,
+ encodeChannelShare,
+ parseChannelShare,
+} from "./channelShare.ts";
+
+function settings(name: string) {
+ return create(Protobuf.Channel.ChannelSettingsSchema, {
+ name,
+ psk: new Uint8Array([1]),
+ });
+}
+
+function channel(
+ index: number,
+ role: Protobuf.Channel.Channel_Role,
+ name?: string,
+) {
+ return create(Protobuf.Channel.ChannelSchema, {
+ index,
+ role,
+ settings: name ? settings(name) : undefined,
+ });
+}
+
+const loraConfig = create(Protobuf.Config.Config_LoRaConfigSchema, {
+ region: Protobuf.Config.Config_LoRaConfig_RegionCode.US,
+});
+
+describe("channel share URLs", () => {
+ it("encodes a Replace share with its LoRa configuration", () => {
+ const url = encodeChannelShare({
+ mode: "replace",
+ settings: [settings("Primary")],
+ loraConfig,
+ });
+
+ expect(url).toMatch(/^https:\/\/meshtastic\.org\/e\/#/);
+ expect(parseChannelShare(url)).toMatchObject({
+ mode: "replace",
+ addOnly: false,
+ channelSet: { loraConfig, settings: [{ name: "Primary" }] },
+ });
+ });
+
+ it("encodes an Add share without LoRa configuration", () => {
+ const url = encodeChannelShare({
+ mode: "add",
+ settings: [settings("Secondary")],
+ loraConfig,
+ });
+
+ expect(url).toContain("/e/?add=true#");
+ const parsed = parseChannelShare(url);
+ expect(parsed).toMatchObject({
+ mode: "add",
+ addOnly: true,
+ channelSet: { settings: [{ name: "Secondary" }] },
+ });
+ expect(parsed.channelSet.loraConfig).toBeUndefined();
+ });
+
+ it("accepts the historic add marker after the fragment", () => {
+ const canonical = encodeChannelShare({
+ mode: "add",
+ settings: [settings("Legacy")],
+ });
+ const legacy = canonical.replace("?add=true#", "#") + "?add=true";
+
+ expect(parseChannelShare(legacy)).toMatchObject({
+ mode: "add",
+ addOnly: true,
+ channelSet: { settings: [{ name: "Legacy" }] },
+ });
+ });
+
+ it("rejects foreign, malformed, ambiguous, and empty channel shares", () => {
+ const valid = encodeChannelShare({
+ mode: "replace",
+ settings: [settings("Valid")],
+ });
+
+ expect(() => parseChannelShare("https://example.com/e/#AA")).toThrow();
+ expect(() =>
+ parseChannelShare(valid.replace("https://", "https://user@")),
+ ).toThrow();
+ expect(() =>
+ parseChannelShare(
+ valid.replace("https://meshtastic.org", "https://meshtastic.org:8443"),
+ ),
+ ).toThrow();
+ expect(() =>
+ parseChannelShare(valid.replace("#", "?add=true&add=true#")),
+ ).toThrow();
+ expect(() =>
+ parseChannelShare("https://meshtastic.org/e/#not base64"),
+ ).toThrow();
+ expect(() => parseChannelShare("https://meshtastic.org/e/#")).toThrow();
+ });
+});
+
+describe("channel import plans", () => {
+ const existing = [
+ channel(0, Protobuf.Channel.Channel_Role.PRIMARY, "Primary"),
+ channel(1, Protobuf.Channel.Channel_Role.SECONDARY, "Existing"),
+ channel(2, Protobuf.Channel.Channel_Role.DISABLED),
+ channel(3, Protobuf.Channel.Channel_Role.DISABLED),
+ ];
+
+ it("defaults a replace-capable share to Replace, clears stale secondary slots, and applies LoRa", () => {
+ const parsed = parseChannelShare(
+ encodeChannelShare({
+ mode: "replace",
+ settings: [settings("Replacement")],
+ loraConfig,
+ }),
+ );
+
+ expect(createChannelImportPlan(parsed, existing, "replace")).toMatchObject({
+ mode: "replace",
+ canApply: true,
+ applyLora: true,
+ assignments: [{ incomingIndex: 0, targetIndex: 0 }],
+ disabledIndexes: [1, 2, 3, 4, 5, 6, 7],
+ });
+ });
+
+ it("locks add-only shares to Add and preserves LoRa", () => {
+ const parsed = parseChannelShare(
+ encodeChannelShare({ mode: "add", settings: [settings("New")] }),
+ );
+
+ expect(createChannelImportPlan(parsed, existing, "replace")).toMatchObject({
+ mode: "add",
+ addOnly: true,
+ canApply: true,
+ applyLora: false,
+ assignments: [{ incomingIndex: 0, targetIndex: 2 }],
+ });
+ });
+
+ it("blocks Add before writes when a channel name conflicts", () => {
+ const parsed = parseChannelShare(
+ encodeChannelShare({ mode: "replace", settings: [settings("existing")] }),
+ );
+
+ expect(createChannelImportPlan(parsed, existing, "add")).toMatchObject({
+ mode: "add",
+ canApply: false,
+ applyLora: false,
+ duplicateNames: ["existing"],
+ });
+ });
+
+ it("blocks Add when there are not enough free secondary slots", () => {
+ const full = Array.from({ length: 8 }, (_, index) =>
+ channel(
+ index,
+ index === 0
+ ? Protobuf.Channel.Channel_Role.PRIMARY
+ : Protobuf.Channel.Channel_Role.SECONDARY,
+ `Existing ${index}`,
+ ),
+ );
+ const parsed = parseChannelShare(
+ encodeChannelShare({ mode: "replace", settings: [settings("New")] }),
+ );
+
+ expect(createChannelImportPlan(parsed, full, "add")).toMatchObject({
+ mode: "add",
+ canApply: false,
+ availableSlots: [],
+ capacityShortfall: 1,
+ });
+ });
+
+ it("treats missing secondary channel indexes as free slots", () => {
+ const parsed = parseChannelShare(
+ encodeChannelShare({ mode: "replace", settings: [settings("New")] }),
+ );
+
+ expect(
+ createChannelImportPlan(parsed, existing.slice(0, 2), "add"),
+ ).toMatchObject({
+ availableSlots: [2, 3, 4, 5, 6, 7],
+ assignments: [{ incomingIndex: 0, targetIndex: 2 }],
+ canApply: true,
+ });
+ });
+
+ it("stages a complete Replace and commits it as one editor transaction", async () => {
+ const parsed = parseChannelShare(
+ encodeChannelShare({
+ mode: "replace",
+ settings: [settings("Replacement")],
+ loraConfig,
+ }),
+ );
+ const plan = createChannelImportPlan(parsed, existing, "replace");
+ const calls: string[] = [];
+ const editor = {
+ isDirty: { value: false },
+ setChannel: (value: Protobuf.Channel.Channel) => {
+ calls.push(`channel:${value.index}:${value.role}`);
+ },
+ setRadioSection: () => calls.push("lora"),
+ commit: async () => {
+ calls.push("commit");
+ return { status: "ok" as const };
+ },
+ };
+
+ await applyChannelImport(editor, parsed, plan, undefined);
+
+ expect(calls).toEqual([
+ `channel:0:${Protobuf.Channel.Channel_Role.PRIMARY}`,
+ `channel:1:${Protobuf.Channel.Channel_Role.DISABLED}`,
+ `channel:2:${Protobuf.Channel.Channel_Role.DISABLED}`,
+ `channel:3:${Protobuf.Channel.Channel_Role.DISABLED}`,
+ `channel:4:${Protobuf.Channel.Channel_Role.DISABLED}`,
+ `channel:5:${Protobuf.Channel.Channel_Role.DISABLED}`,
+ `channel:6:${Protobuf.Channel.Channel_Role.DISABLED}`,
+ `channel:7:${Protobuf.Channel.Channel_Role.DISABLED}`,
+ "lora",
+ "commit",
+ ]);
+ });
+
+ it("propagates an editor commit error without clearing the staged import", async () => {
+ const parsed = parseChannelShare(
+ encodeChannelShare({ mode: "replace", settings: [settings("New")] }),
+ );
+ const plan = createChannelImportPlan(parsed, existing, "replace");
+ const error = new Error("device rejected import");
+ const editor = {
+ isDirty: { value: false },
+ setChannel: () => {},
+ setRadioSection: () => {},
+ commit: async () => ({ status: "error" as const, error }),
+ };
+
+ await expect(
+ applyChannelImport(editor, parsed, plan, undefined),
+ ).rejects.toThrow(error);
+ });
+
+ it("does not commit an import through an editor with unrelated pending changes", async () => {
+ const parsed = parseChannelShare(
+ encodeChannelShare({ mode: "replace", settings: [settings("New")] }),
+ );
+ const plan = createChannelImportPlan(parsed, existing, "replace");
+ const calls: string[] = [];
+ const editor = {
+ isDirty: { value: true },
+ setChannel: () => calls.push("channel"),
+ setRadioSection: () => calls.push("lora"),
+ commit: async () => {
+ calls.push("commit");
+ return { status: "ok" as const };
+ },
+ };
+
+ await expect(
+ applyChannelImport(editor, parsed, plan, undefined),
+ ).rejects.toThrow(
+ "Save or discard pending settings changes before importing channels.",
+ );
+ expect(calls).toEqual([]);
+ });
+});
diff --git a/apps/web/src/core/utils/channelShare.ts b/apps/web/src/core/utils/channelShare.ts
new file mode 100644
index 000000000..8fde2ca90
--- /dev/null
+++ b/apps/web/src/core/utils/channelShare.ts
@@ -0,0 +1,299 @@
+import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
+import { Protobuf } from "@meshtastic/sdk";
+import { fromByteArray, toByteArray } from "base64-js";
+
+const CHANNEL_SHARE_HOST = "meshtastic.org";
+const CHANNEL_SHARE_PATH = "/e/";
+const MAX_CHANNELS = 8;
+
+export type ChannelShareMode = "replace" | "add";
+
+export interface ParsedChannelShare {
+ channelSet: Protobuf.AppOnly.ChannelSet;
+ mode: ChannelShareMode;
+ addOnly: boolean;
+}
+
+export interface ChannelImportAssignment {
+ incomingIndex: number;
+ targetIndex: number;
+}
+
+export interface ChannelImportPlan {
+ mode: ChannelShareMode;
+ addOnly: boolean;
+ applyLora: boolean;
+ assignments: ChannelImportAssignment[];
+ disabledIndexes: number[];
+ availableSlots: number[];
+ duplicateNames: string[];
+ capacityShortfall: number;
+ canApply: boolean;
+}
+
+export interface ChannelImportEditor {
+ isDirty: { value: boolean };
+ setChannel(channel: Protobuf.Channel.Channel): void;
+ setRadioSection(
+ section: "lora",
+ config: Protobuf.Config.Config_LoRaConfig,
+ ): void;
+ commit(): Promise<{ status: "ok" } | { status: "error"; error: Error }>;
+}
+
+export function encodeChannelShare({
+ mode,
+ settings,
+ loraConfig,
+}: {
+ mode: ChannelShareMode;
+ settings: readonly Protobuf.Channel.ChannelSettings[];
+ loraConfig?: Protobuf.Config.Config_LoRaConfig;
+}): string {
+ if (settings.length === 0 || settings.length > MAX_CHANNELS) {
+ throw new Error(
+ "A channel share must include between one and eight channels.",
+ );
+ }
+
+ const channelSet = create(Protobuf.AppOnly.ChannelSetSchema, {
+ settings: [...settings],
+ loraConfig: mode === "replace" ? loraConfig : undefined,
+ });
+ const payload = fromByteArray(
+ toBinary(Protobuf.AppOnly.ChannelSetSchema, channelSet),
+ )
+ .replace(/=/g, "")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_");
+
+ return `https://${CHANNEL_SHARE_HOST}${CHANNEL_SHARE_PATH}${
+ mode === "add" ? "?add=true" : ""
+ }#${payload}`;
+}
+
+export function parseChannelShare(input: string): ParsedChannelShare {
+ const url = new URL(input);
+ if (
+ url.protocol !== "https:" ||
+ url.hostname !== CHANNEL_SHARE_HOST ||
+ url.pathname !== CHANNEL_SHARE_PATH ||
+ url.port !== "" ||
+ url.username ||
+ url.password ||
+ !url.hash
+ ) {
+ throw new Error("Invalid Meshtastic channel URL.");
+ }
+
+ const queryMode = parseShareMode(url.searchParams);
+ let payload = url.hash.substring(1);
+ const legacyAdd = payload.endsWith("?add=true");
+ if (legacyAdd) payload = payload.slice(0, -"?add=true".length);
+
+ if (!payload || !/^[A-Za-z0-9_-]+$/.test(payload)) {
+ throw new Error("Invalid Meshtastic channel payload.");
+ }
+
+ const paddedPayload = payload
+ .padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "=")
+ .replace(/-/g, "+")
+ .replace(/_/g, "/");
+ const decoded = fromBinary(
+ Protobuf.AppOnly.ChannelSetSchema,
+ toByteArray(paddedPayload),
+ );
+
+ if (decoded.settings.length === 0 || decoded.settings.length > MAX_CHANNELS) {
+ throw new Error("Channel share does not contain a valid channel set.");
+ }
+
+ const mode: ChannelShareMode =
+ queryMode === "add" || legacyAdd ? "add" : "replace";
+ return {
+ mode,
+ addOnly: mode === "add",
+ // A malformed add-only payload can never carry LoRa settings into an Add import.
+ channelSet:
+ mode === "add"
+ ? create(Protobuf.AppOnly.ChannelSetSchema, {
+ settings: decoded.settings,
+ })
+ : decoded,
+ };
+}
+
+export function createChannelImportPlan(
+ share: ParsedChannelShare,
+ existingChannels: readonly Protobuf.Channel.Channel[],
+ requestedMode: ChannelShareMode,
+ selectedSlots?: readonly number[],
+): ChannelImportPlan {
+ const mode = share.addOnly ? "add" : requestedMode;
+ const channelsByIndex = new Map(
+ existingChannels.map((channel) => [channel.index, channel]),
+ );
+ const availableSlots = Array.from(
+ { length: MAX_CHANNELS - 1 },
+ (_, index) => index + 1,
+ ).filter((index) => {
+ const channel = channelsByIndex.get(index);
+ return (
+ channel === undefined ||
+ channel.role === Protobuf.Channel.Channel_Role.DISABLED ||
+ channel.settings === undefined
+ );
+ });
+
+ if (mode === "replace") {
+ const assignments = share.channelSet.settings.map((_, incomingIndex) => ({
+ incomingIndex,
+ targetIndex: selectedSlots?.[incomingIndex] ?? incomingIndex,
+ }));
+ const duplicateTargets = new Set();
+ const seenTargets = new Set();
+ for (const assignment of assignments) {
+ if (assignment.targetIndex < 0) continue;
+ if (seenTargets.has(assignment.targetIndex)) {
+ duplicateTargets.add(assignment.targetIndex);
+ }
+ seenTargets.add(assignment.targetIndex);
+ }
+
+ return {
+ mode,
+ addOnly: false,
+ applyLora: share.channelSet.loraConfig !== undefined,
+ assignments,
+ disabledIndexes: Array.from(
+ { length: MAX_CHANNELS - 1 },
+ (_, index) => index + 1,
+ ).filter(
+ (index) =>
+ !assignments.some((assignment) => assignment.targetIndex === index),
+ ),
+ availableSlots,
+ duplicateNames: [],
+ capacityShortfall: 0,
+ canApply:
+ duplicateTargets.size === 0 &&
+ assignments.some((assignment) => assignment.targetIndex >= 0),
+ };
+ }
+
+ const existingNames = new Set(
+ existingChannels
+ .map((channel) => channel.settings?.name.trim().toLocaleLowerCase())
+ .filter((name): name is string => Boolean(name)),
+ );
+ const incomingNames = new Set();
+ const duplicateNames = new Set();
+ for (const channel of share.channelSet.settings) {
+ const name = channel.name.trim().toLocaleLowerCase();
+ if (!name) continue;
+ if (existingNames.has(name) || incomingNames.has(name))
+ duplicateNames.add(name);
+ incomingNames.add(name);
+ }
+
+ const assignments = share.channelSet.settings.map((_, incomingIndex) => ({
+ incomingIndex,
+ targetIndex:
+ selectedSlots?.[incomingIndex] ?? availableSlots[incomingIndex] ?? -1,
+ }));
+ const invalidTarget = assignments.some(
+ (assignment) =>
+ assignment.targetIndex !== -1 &&
+ !availableSlots.includes(assignment.targetIndex),
+ );
+ const selectedTargets = assignments
+ .map((assignment) => assignment.targetIndex)
+ .filter((targetIndex) => targetIndex >= 0);
+ const hasDuplicateTargets =
+ new Set(selectedTargets).size !== selectedTargets.length;
+ const capacityShortfall = Math.max(
+ 0,
+ share.channelSet.settings.length - availableSlots.length,
+ );
+
+ return {
+ mode,
+ addOnly: true,
+ applyLora: false,
+ assignments,
+ disabledIndexes: [],
+ availableSlots,
+ duplicateNames: [...duplicateNames],
+ capacityShortfall,
+ canApply:
+ duplicateNames.size === 0 &&
+ capacityShortfall === 0 &&
+ !invalidTarget &&
+ !hasDuplicateTargets &&
+ assignments.every((assignment) => assignment.targetIndex >= 0),
+ };
+}
+
+export async function applyChannelImport(
+ editor: ChannelImportEditor,
+ share: ParsedChannelShare,
+ plan: ChannelImportPlan,
+ currentLora: Protobuf.Config.Config_LoRaConfig | undefined,
+): Promise {
+ if (!plan.canApply) {
+ throw new Error("Channel import plan cannot be applied.");
+ }
+ if (editor.isDirty.value) {
+ throw new Error(
+ "Save or discard pending settings changes before importing channels.",
+ );
+ }
+
+ for (const assignment of plan.assignments) {
+ const settings = share.channelSet.settings[assignment.incomingIndex];
+ if (!settings || assignment.targetIndex < 0) continue;
+ editor.setChannel(
+ create(Protobuf.Channel.ChannelSchema, {
+ index: assignment.targetIndex,
+ role:
+ assignment.targetIndex === 0
+ ? Protobuf.Channel.Channel_Role.PRIMARY
+ : Protobuf.Channel.Channel_Role.SECONDARY,
+ settings,
+ }),
+ );
+ }
+
+ for (const index of plan.disabledIndexes) {
+ editor.setChannel(
+ create(Protobuf.Channel.ChannelSchema, {
+ index,
+ role: Protobuf.Channel.Channel_Role.DISABLED,
+ }),
+ );
+ }
+
+ if (plan.applyLora && share.channelSet.loraConfig) {
+ editor.setRadioSection("lora", {
+ ...currentLora,
+ ...share.channelSet.loraConfig,
+ } as Protobuf.Config.Config_LoRaConfig);
+ }
+
+ const result = await editor.commit();
+ if (result.status === "error") throw result.error;
+}
+
+function parseShareMode(search: URLSearchParams): ChannelShareMode {
+ if (
+ [...search.keys()].some((key) => key !== "add") ||
+ search.getAll("add").length > 1
+ ) {
+ throw new Error("Invalid Meshtastic channel URL.");
+ }
+ const add = search.get("add");
+ if (add !== null && add !== "true") {
+ throw new Error("Invalid Meshtastic channel URL.");
+ }
+ return add === "true" ? "add" : "replace";
+}