diff --git a/apps/web/public/i18n/locales/en/dialog.json b/apps/web/public/i18n/locales/en/dialog.json index 5d8e36b40..3b064c39b 100644 --- a/apps/web/public/i18n/locales/en/dialog.json +++ b/apps/web/public/i18n/locales/en/dialog.json @@ -4,19 +4,29 @@ "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" + "invalidUrl": "Invalid Meshtastic URL", + "applyFailed": { + "title": "Channel import failed", + "description": "The device did not accept the channel changes. Please try again." + } }, "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.", + "pendingChanges": "Save or discard pending settings changes before importing 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 +170,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; } +const CLEAN_EDITOR_SIGNAL = { + value: false, + peek: () => false, + subscribe: () => () => {}, +} as const; + export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { const { config } = useDevice(); const editor = useConfigEditor(); + const channels = useChannels(); + const editorIsDirty = useSignal(editor?.isDirty ?? CLEAN_EDITOR_SIGNAL); + const { toast } = useToast(); 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 [input, setInput] = useState(""); + const [share, setShare] = useState(); + const [mode, setMode] = useState("replace"); + const [selectedSlots, setSelectedSlots] = useState(); + const [isApplying, setIsApplying] = useState(false); - const encodedChannelConfig = channelsUrl.hash.substring(1); - const paddedString = encodedChannelConfig - .padEnd( - encodedChannelConfig.length + - ((4 - (encodedChannelConfig.length % 4)) % 4), - "=", - ) - .replace(/-/g, "+") - .replace(/_/g, "/"); - - 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 payload = create(Protobuf.Channel.ChannelSchema, { - index: importIndex[index], - role: - importIndex[index] === 0 - ? Protobuf.Channel.Channel_Role.PRIMARY - : Protobuf.Channel.Channel_Role.SECONDARY, - settings: ch, - }); + const changeMode = (nextMode: ChannelShareMode) => { + setMode(nextMode); + setSelectedSlots(undefined); + }; - editor.setChannel(payload); - }, + const changeSlot = (incomingIndex: number, targetIndex: number) => { + if (!plan) return; + const nextSlots = plan.assignments.map( + (assignment) => assignment.targetIndex, ); + nextSlots[incomingIndex] = targetIndex; + setSelectedSlots(nextSlots); + }; - if (channelSet?.loraConfig && updateConfig) { - const payload = { - ...config.lora, - ...channelSet.loraConfig, - } as Protobuf.Config.Config_LoRaConfig; - editor.setRadioSection("lora", payload); + const apply = async () => { + if (!editor || !share || !plan || !plan.canApply || editorIsDirty) return; + setIsApplying(true); + try { + await applyChannelImport(editor, share, plan, config.lora); + parse(""); + onOpenChange(false); + } catch (error) { + toast({ + title: t("import.error.applyFailed.title"), + description: + error instanceof Error + ? error.message + : t("import.error.applyFailed.description"), + }); + } finally { + setIsApplying(false); } - // Reset state after import - setImportDialogInput(""); - setChannelSet(undefined); - setValidUrl(false); - setImportIndex([]); - setUpdateConfig(true); - - 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 ( @@ -134,7 +140,7 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => { {t("import.title")} , br:
}} />
@@ -142,83 +148,119 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
{ - 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 ( +
+ {(["replace", "add"] as const).map((modeOption) => ( + + ))} +
+ ); +}; 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 ( @@ -62,9 +78,18 @@ export const QRDialog = ({ open, onOpenChange, loraConfig }: QRDialogProps) => { {t("qr.title")} - {t("qr.description")} + + {t( + mode === "replace" + ? "qr.replaceDescription" + : "qr.addDescription", + )} +
+
+ +
{allChannels.map((channel) => ( @@ -80,13 +105,16 @@ export const QRDialog = ({ open, onOpenChange, loraConfig }: QRDialogProps) => { })}`} { if (selectedChannels.includes(channel.index)) { - setSelectedChannels( - selectedChannels.filter((c) => c !== channel.index), - ); + if (selectedChannels.length > 1) { + setSelectedChannels( + selectedChannels.filter( + (selected) => selected !== channel.index, + ), + ); + } } else { setSelectedChannels([ ...selectedChannels, @@ -100,38 +128,45 @@ export const QRDialog = ({ open, onOpenChange, loraConfig }: QRDialogProps) => {
-
- - -
- + - + +
); }; + +const ModePicker = ({ + mode, + onChange, +}: { + mode: ChannelShareMode; + onChange: (mode: ChannelShareMode) => void; +}) => { + const { t } = useTranslation("dialog"); + return ( +
+ {(["replace", "add"] as const).map((modeOption) => ( + + ))} +
+ ); +}; 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"; +}