From 5a8f2d42ea43557e775248af0d7695f8a738abd9 Mon Sep 17 00:00:00 2001
From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Date: Sun, 19 Jul 2026 12:05:17 -0700
Subject: [PATCH 1/3] feat(web): add safe channel share workflow
---
apps/web/public/i18n/locales/en/dialog.json | 23 +-
.../src/components/Dialog/DialogManager.tsx | 1 -
.../src/components/Dialog/ImportDialog.tsx | 353 ++++++++++--------
apps/web/src/components/Dialog/QRDialog.tsx | 155 +++++---
apps/web/src/core/utils/channelShare.test.ts | 187 ++++++++++
apps/web/src/core/utils/channelShare.ts | 229 ++++++++++++
6 files changed, 733 insertions(+), 215 deletions(-)
create mode 100644 apps/web/src/core/utils/channelShare.test.ts
create mode 100644 apps/web/src/core/utils/channelShare.ts
diff --git a/apps/web/public/i18n/locales/en/dialog.json b/apps/web/public/i18n/locales/en/dialog.json
index 5d8e36b40..ca85289c7 100644
--- a/apps/web/public/i18n/locales/en/dialog.json
+++ b/apps/web/public/i18n/locales/en/dialog.json
@@ -4,19 +4,24 @@
"title": "Clear All Messages"
},
"import": {
- "description": "Import a Channel Set from a Meshtastic URL.
Valid Meshtasic URLs start with \"https://meshtastic.org/e/...\"",
+ "description": "Import a Channel Set from a Meshtastic URL.
Valid Meshtastic URLs start with \"https://meshtastic.org/e/...\"",
"error": {
"invalidUrl": "Invalid Meshtastic URL"
},
"channelPrefix": "Channel ",
"primary": "Primary ",
- "doNotImport": "No not import",
"channelName": "Name",
"channelSlot": "Slot",
"channelSetUrl": "Channel Set/QR Code URL",
- "useLoraConfig": "Import LoRa Config",
- "presetDescription": "The current LoRa Config will be replaced.",
- "title": "Import Channels"
+ "title": "Import Channels",
+ "mode": "Channel import mode",
+ "replaceDescription": "Replace changes the selected channel slots and applies LoRa settings when included.",
+ "addDescription": "Add uses free secondary slots and preserves the current channels and LoRa settings.",
+ "addOnly": "This share can only add channels.",
+ "loraWillApply": "LoRa settings from this share will be applied.",
+ "loraWillNotApply": "LoRa settings will not be changed.",
+ "duplicateNames": "Channel names already exist: {{names}}. Resolve the conflict before adding.",
+ "capacity": "Not enough free secondary channel slots: {{available}} available for {{needed}} channels."
},
"locationResponse": {
"title": "Location: {{identifier}}",
@@ -160,9 +165,13 @@
"qr": {
"addChannels": "Add Channels",
"replaceChannels": "Replace Channels",
- "description": "The current LoRa configuration will also be shared.",
"sharableUrl": "Sharable URL",
- "title": "Generate QR Code"
+ "title": "Generate QR Code",
+ "mode": "Channel share mode",
+ "replaceDescription": "Replace shares the selected channels and LoRa settings.",
+ "addDescription": "Add shares selected channels only and preserves the receiver's existing channels and LoRa settings.",
+ "share": "Share",
+ "shareText": "Meshtastic channel share"
},
"reboot": {
"title": "Reboot device",
diff --git a/apps/web/src/components/Dialog/DialogManager.tsx b/apps/web/src/components/Dialog/DialogManager.tsx
index 2302915e2..1a5464223 100644
--- a/apps/web/src/components/Dialog/DialogManager.tsx
+++ b/apps/web/src/components/Dialog/DialogManager.tsx
@@ -31,7 +31,6 @@ export const DialogManager = () => {
onOpenChange={(open) => {
setDialogOpen("import", open);
}}
- loraConfig={config.lora}
/>
void;
- loraConfig?: Protobuf.Config.Config_LoRaConfig;
}
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
const { config } = useDevice();
const editor = useConfigEditor();
+ const channels = useChannels();
const { t } = useTranslation("dialog");
- const [importDialogInput, setImportDialogInput] = useState("");
- const [channelSet, setChannelSet] = useState();
- const [validUrl, setValidUrl] = useState(false);
- const [updateConfig, setUpdateConfig] = useState(true);
- const [importIndex, setImportIndex] = useState([]);
-
- useEffect(() => {
- // the channel information is contained in the URL's fragment, which will be present after a
- // non-URL encoded `#`.
- try {
- const channelsUrl = new URL(importDialogInput);
- if (
- (channelsUrl.hostname !== "meshtastic.org" &&
- channelsUrl.pathname !== "/e/") ||
- !channelsUrl.hash
- ) {
- throw t("import.error.invalidUrl");
- }
-
- const encodedChannelConfig = channelsUrl.hash.substring(1);
- const paddedString = encodedChannelConfig
- .padEnd(
- encodedChannelConfig.length +
- ((4 - (encodedChannelConfig.length % 4)) % 4),
- "=",
- )
- .replace(/-/g, "+")
- .replace(/_/g, "/");
+ const [input, setInput] = useState("");
+ const [share, setShare] = useState();
+ const [mode, setMode] = useState("replace");
+ const [selectedSlots, setSelectedSlots] = useState();
- const newChannelSet = fromBinary(
- Protobuf.AppOnly.ChannelSetSchema,
- toByteArray(paddedString),
- );
-
- const newImportChannelArray = newChannelSet.settings.map((_, idx) => idx);
+ const existingChannels = useMemo(
+ () =>
+ channels.map((channel) =>
+ create(Protobuf.Channel.ChannelSchema, {
+ index: channel.index,
+ role: channel.role,
+ settings: channel.settings,
+ }),
+ ),
+ [channels],
+ );
+ const plan = useMemo(
+ () =>
+ share
+ ? createChannelImportPlan(share, existingChannels, mode, selectedSlots)
+ : undefined,
+ [existingChannels, mode, selectedSlots, share],
+ );
- setChannelSet(newChannelSet);
- setImportIndex(newImportChannelArray);
- setUpdateConfig(newChannelSet?.loraConfig !== undefined);
- setValidUrl(true);
+ const parse = (value: string) => {
+ setInput(value);
+ if (!value) {
+ setShare(undefined);
+ setSelectedSlots(undefined);
+ return;
+ }
+ try {
+ const parsed = parseChannelShare(value);
+ setShare(parsed);
+ setMode(parsed.addOnly ? "add" : "replace");
+ setSelectedSlots(undefined);
} catch {
- setValidUrl(false);
- setChannelSet(undefined);
+ setShare(undefined);
+ setSelectedSlots(undefined);
}
- }, [importDialogInput, t]);
+ };
- const apply = () => {
- if (!editor) return;
- channelSet?.settings.forEach(
- (ch: Protobuf.Channel.ChannelSettings, index: number) => {
- if (importIndex[index] === -1) {
- return;
- }
+ const changeMode = (nextMode: ChannelShareMode) => {
+ setMode(nextMode);
+ setSelectedSlots(undefined);
+ };
+
+ const changeSlot = (incomingIndex: number, targetIndex: number) => {
+ if (!plan) return;
+ const nextSlots = plan.assignments.map(
+ (assignment) => assignment.targetIndex,
+ );
+ nextSlots[incomingIndex] = targetIndex;
+ setSelectedSlots(nextSlots);
+ };
- const payload = create(Protobuf.Channel.ChannelSchema, {
- index: importIndex[index],
+ const apply = () => {
+ if (!editor || !share || !plan || !plan.canApply) return;
+ 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:
- importIndex[index] === 0
+ assignment.targetIndex === 0
? Protobuf.Channel.Channel_Role.PRIMARY
: Protobuf.Channel.Channel_Role.SECONDARY,
- settings: ch,
- });
-
- editor.setChannel(payload);
- },
- );
-
- if (channelSet?.loraConfig && updateConfig) {
- const payload = {
+ settings,
+ }),
+ );
+ }
+ if (plan.applyLora && share.channelSet.loraConfig) {
+ editor.setRadioSection("lora", {
...config.lora,
- ...channelSet.loraConfig,
- } as Protobuf.Config.Config_LoRaConfig;
- editor.setRadioSection("lora", payload);
+ ...share.channelSet.loraConfig,
+ } as Protobuf.Config.Config_LoRaConfig);
}
- // Reset state after import
- setImportDialogInput("");
- setChannelSet(undefined);
- setValidUrl(false);
- setImportIndex([]);
- setUpdateConfig(true);
-
+ parse("");
onOpenChange(false);
};
- const onSelectChange = (value: string, index: number) => {
- const newImportIndex = [...importIndex];
- newImportIndex[index] = Number.parseInt(value, 10);
- setImportIndex(newImportIndex);
- };
+ const slotOptions =
+ plan?.mode === "add"
+ ? plan.availableSlots
+ : Array.from({ length: 8 }, (_, index) => index);
return (
+ {editorIsDirty && (
+
+ {t("import.pendingChanges")}
+
+ )}
{share.addOnly && (
{t("import.addOnly")}
@@ -245,7 +257,7 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
void apply()}
>
diff --git a/apps/web/src/core/utils/channelShare.test.ts b/apps/web/src/core/utils/channelShare.test.ts
index ae14d4972..292f0e049 100644
--- a/apps/web/src/core/utils/channelShare.test.ts
+++ b/apps/web/src/core/utils/channelShare.test.ts
@@ -203,6 +203,7 @@ describe("channel import plans", () => {
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}`);
},
@@ -236,6 +237,7 @@ describe("channel import plans", () => {
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 }),
@@ -245,4 +247,28 @@ describe("channel import plans", () => {
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
index 1582c78b8..8fde2ca90 100644
--- a/apps/web/src/core/utils/channelShare.ts
+++ b/apps/web/src/core/utils/channelShare.ts
@@ -32,6 +32,7 @@ export interface ChannelImportPlan {
}
export interface ChannelImportEditor {
+ isDirty: { value: boolean };
setChannel(channel: Protobuf.Channel.Channel): void;
setRadioSection(
section: "lora",
@@ -242,6 +243,11 @@ export async function applyChannelImport(
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];