mirror of
https://github.com/brues-code/pfUI.git
synced 2026-10-02 03:39:59 +00:00
share: rebuild profile export/import on C_EncodingUtil (CBOR+zlib+base64)
Export now diffs the config against defaults and emits SerializeCBOR -> CompressString (zlib) -> EncodeBase64 with a "!pf1!" prefix. Import reverses that into a plain table — no loadstring, so a pasted profile is data and can't execute code. The Decode/Encode button converts blob <-> editable JSON (SerializeJSON/DeserializeJSON) for inspection and hand-edits. Old-format strings still import: standard base64 via DecodeBase64, the custom LZW decompressor kept import-only, and the resulting Lua source runs in an EMPTY setfenv sandbox that can only assign its config table. Decode on a legacy string yields the JSON view, so Encode re-emits it as a new-format blob (migration path). Roughly half the paste size of the old LZW format, C-speed instead of the old bit-string base64 (which froze the client on large configs), and the format is fully standard — external tools can decode profiles with stock base64/zlib/CBOR libraries. Requires ClassicAPI with the SerializeCBOR buffer-growth fix (payloads over 256 bytes returned nil before it).
This commit is contained in:
+131
-194
@@ -1,93 +1,78 @@
|
||||
pfUI:RegisterModule("share", function ()
|
||||
local function serialize(tbl, comp, name, ignored, spacing)
|
||||
local spacing = spacing or ""
|
||||
local match = nil
|
||||
local tname = ( spacing == "" and "" or "[\"" ) .. name .. ( spacing == "" and "" or "\"]" )
|
||||
local str = spacing .. tname .. " = {\n"
|
||||
-- Profile sharing rides ClassicAPI's C_EncodingUtil:
|
||||
-- export: diff vs defaults -> SerializeCBOR -> CompressString (zlib)
|
||||
-- -> EncodeBase64, prefixed with the format magic
|
||||
-- import: the reverse, into a plain table — no loadstring, so a
|
||||
-- pasted profile can't execute code
|
||||
-- The Decode/Encode button converts between the transport blob and an
|
||||
-- editable JSON view of the same table.
|
||||
local MAGIC = "!pf1!"
|
||||
|
||||
-- Deep-diff `tbl` against `default`, keeping only changed or added
|
||||
-- values. `ignored` keys are skipped at the top level only (matches the
|
||||
-- old serializer: "position"/"disabled" when Ignore Layout is checked).
|
||||
-- Returns nil when nothing differs.
|
||||
local function DiffConfig(tbl, default, ignored)
|
||||
local diff = nil
|
||||
for k, v in pairs(tbl) do
|
||||
if not ( ignored[k] and spacing == "" ) and ( not comp or not comp[k] or comp[k] ~= tbl[k] ) then
|
||||
if not ( ignored and ignored[k] ) then
|
||||
local dv = default and default[k]
|
||||
if type(v) == "table" then
|
||||
local result = serialize(tbl[k], comp and comp[k], k, ignored, spacing .. " ")
|
||||
if result then
|
||||
match = true
|
||||
str = str .. result
|
||||
local sub = DiffConfig(v, type(dv) == "table" and dv or nil)
|
||||
if sub then
|
||||
diff = diff or {}
|
||||
diff[k] = sub
|
||||
end
|
||||
elseif type(v) == "string" then
|
||||
match = true
|
||||
local escaped = string.gsub(v, "\\", "\\\\")
|
||||
escaped = string.gsub(escaped, "\"", "\\\"")
|
||||
str = str .. spacing .. " [\""..k.."\"] = \"".. escaped .."\",\n"
|
||||
elseif type(v) == "number" then
|
||||
match = true
|
||||
str = str .. spacing .. " [\""..k.."\"] = ".. string.gsub(v, "\\", "\\\\") ..",\n"
|
||||
elseif v ~= dv and ( type(v) == "string" or type(v) == "number" or type(v) == "boolean" ) then
|
||||
diff = diff or {}
|
||||
diff[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
str = str .. spacing .. "}" .. ( spacing == "" and "" or "," ) .. "\n"
|
||||
return match and str or nil
|
||||
return diff
|
||||
end
|
||||
|
||||
local function compress(input)
|
||||
-- based on Rochet2's lzw compression
|
||||
if type(input) ~= "string" then
|
||||
return nil
|
||||
-- EditBoxes don't soft-wrap one giant unbroken line; chunk the blob.
|
||||
local function wrap(str, width)
|
||||
local out = {}
|
||||
for i = 1, strlen(str), width do
|
||||
table.insert(out, strsub(str, i, i + width - 1))
|
||||
end
|
||||
local len = strlen(input)
|
||||
if len <= 1 then
|
||||
return "u"..input
|
||||
end
|
||||
|
||||
local dict = {}
|
||||
for i = 0, 255 do
|
||||
local ic, iic = strchar(i), strchar(i, 0)
|
||||
dict[ic] = iic
|
||||
end
|
||||
local a, b = 0, 1
|
||||
|
||||
local result = {"c"}
|
||||
local resultlen = 1
|
||||
local n = 2
|
||||
local word = ""
|
||||
for i = 1, len do
|
||||
local c = strsub(input, i, i)
|
||||
local wc = word..c
|
||||
if not dict[wc] then
|
||||
local write = dict[word]
|
||||
if not write then
|
||||
return nil
|
||||
end
|
||||
result[n] = write
|
||||
resultlen = resultlen + strlen(write)
|
||||
n = n+1
|
||||
if len <= resultlen then
|
||||
return "u"..input
|
||||
end
|
||||
local str = wc
|
||||
if a >= 256 then
|
||||
a, b = 0, b+1
|
||||
if b >= 256 then
|
||||
dict = {}
|
||||
b = 1
|
||||
end
|
||||
end
|
||||
dict[str] = strchar(a,b)
|
||||
a = a+1
|
||||
word = c
|
||||
else
|
||||
word = wc
|
||||
end
|
||||
end
|
||||
result[n] = dict[word]
|
||||
resultlen = resultlen+strlen(result[n])
|
||||
n = n+1
|
||||
if len <= resultlen then
|
||||
return "u"..input
|
||||
end
|
||||
return table.concat(result)
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
local function Encode(tbl)
|
||||
return MAGIC .. wrap(C_EncodingUtil.EncodeBase64(
|
||||
C_EncodingUtil.CompressString(
|
||||
C_EncodingUtil.SerializeCBOR(tbl))), 92)
|
||||
end
|
||||
|
||||
local function Decode(text)
|
||||
if not text then return nil end
|
||||
text = gsub(text, "%s", "")
|
||||
if strsub(text, 1, strlen(MAGIC)) ~= MAGIC then return nil end
|
||||
local ok, result = pcall(function()
|
||||
return C_EncodingUtil.DeserializeCBOR(
|
||||
C_EncodingUtil.DecompressString(
|
||||
C_EncodingUtil.DecodeBase64(strsub(text, strlen(MAGIC) + 1))))
|
||||
end)
|
||||
if ok and type(result) == "table" then return result end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function DecodeJSON(text)
|
||||
if not text or gsub(text, "%s", "") == "" then return nil end
|
||||
local ok, result = pcall(function()
|
||||
return C_EncodingUtil.DeserializeJSON(text)
|
||||
end)
|
||||
if ok and type(result) == "table" then return result end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Legacy import (pre-!pf1! exports): base64 -> LZW -> Lua source
|
||||
-- "pfUI_config = {...}". The base64 layer is standard and handled by
|
||||
-- C_EncodingUtil.DecodeBase64; only the custom LZW format needs the old
|
||||
-- Lua decoder. Import-only — new exports always use the CBOR pipeline.
|
||||
local function decompress(input)
|
||||
-- based on Rochet2's lzw compression
|
||||
if type(input) ~= "string" or strlen(input) < 1 then
|
||||
@@ -159,92 +144,32 @@ pfUI:RegisterModule("share", function ()
|
||||
return table.concat(result)
|
||||
end
|
||||
|
||||
local function enc(to_encode)
|
||||
local index_table = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
local bit_pattern = ''
|
||||
local encoded = ''
|
||||
local trailing = ''
|
||||
local function DecodeLegacy(text)
|
||||
if not text or gsub(text, "%s", "") == "" then return nil end
|
||||
|
||||
for i = 1, string.len(to_encode) do
|
||||
local remaining = tonumber(string.byte(string.sub(to_encode, i, i)))
|
||||
local bin_bits = ''
|
||||
for i = 7, 0, -1 do
|
||||
local current_power = math.pow(2, i)
|
||||
if remaining >= current_power then
|
||||
bin_bits = bin_bits .. '1'
|
||||
remaining = remaining - current_power
|
||||
else
|
||||
bin_bits = bin_bits .. '0'
|
||||
end
|
||||
end
|
||||
bit_pattern = bit_pattern .. bin_bits
|
||||
-- encoded blob? peel base64 + LZW down to Lua source. Raw (already
|
||||
-- decoded) source pastes are accepted as-is.
|
||||
local source = text
|
||||
local stripped = gsub(text, "%s", "")
|
||||
local ok, decoded = pcall(function()
|
||||
return C_EncodingUtil.DecodeBase64(stripped)
|
||||
end)
|
||||
if ok and decoded then
|
||||
local decompressed = decompress(decoded)
|
||||
if decompressed then source = decompressed end
|
||||
end
|
||||
|
||||
if mod(string.len(bit_pattern), 3) == 2 then
|
||||
trailing = '=='
|
||||
bit_pattern = bit_pattern .. '0000000000000000'
|
||||
elseif mod(string.len(bit_pattern), 3) == 1 then
|
||||
trailing = '='
|
||||
bit_pattern = bit_pattern .. '00000000'
|
||||
end
|
||||
local chunk = loadstring(source)
|
||||
if not chunk then return nil end
|
||||
|
||||
local count = 0
|
||||
for i = 1, string.len(bit_pattern), 6 do
|
||||
local byte = string.sub(bit_pattern, i, i+5)
|
||||
local offset = tonumber(tonumber(byte, 2))
|
||||
encoded = encoded .. string.sub(index_table, offset+1, offset+1)
|
||||
count = count + 1
|
||||
if count >= 92 then
|
||||
encoded = encoded .. "\n"
|
||||
count = 0
|
||||
end
|
||||
end
|
||||
|
||||
return string.sub(encoded, 1, -1 - string.len(trailing)) .. trailing
|
||||
end
|
||||
|
||||
local function dec(to_decode)
|
||||
local index_table = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
local padded = gsub(to_decode,"%s", "")
|
||||
local unpadded = gsub(padded,"=", "")
|
||||
local bit_pattern = ''
|
||||
local decoded = ''
|
||||
|
||||
to_decode = gsub(to_decode,"\n", "")
|
||||
to_decode = gsub(to_decode," ", "")
|
||||
|
||||
for i = 1, string.len(unpadded) do
|
||||
local char = string.sub(to_decode, i, i)
|
||||
local offset, _ = string.find(index_table, char)
|
||||
if offset == nil then return nil end
|
||||
|
||||
local remaining = tonumber(offset-1)
|
||||
local bin_bits = ''
|
||||
for i = 7, 0, -1 do
|
||||
local current_power = math.pow(2, i)
|
||||
if remaining >= current_power then
|
||||
bin_bits = bin_bits .. '1'
|
||||
remaining = remaining - current_power
|
||||
else
|
||||
bin_bits = bin_bits .. '0'
|
||||
end
|
||||
end
|
||||
|
||||
bit_pattern = bit_pattern .. string.sub(bin_bits, 3)
|
||||
end
|
||||
|
||||
for i = 1, string.len(bit_pattern), 8 do
|
||||
local byte = string.sub(bit_pattern, i, i+7)
|
||||
decoded = decoded .. strchar(tonumber(byte, 2))
|
||||
end
|
||||
|
||||
local padding_length = string.len(padded)-string.len(unpadded)
|
||||
|
||||
if (padding_length == 1 or padding_length == 2) then
|
||||
decoded = string.sub(decoded,1,-2)
|
||||
end
|
||||
|
||||
return decoded
|
||||
-- Sandbox the chunk: an empty environment means it can assign its
|
||||
-- config table but can't reach any global or API function — a legacy
|
||||
-- profile string is data, never code.
|
||||
local sandbox = {}
|
||||
setfenv(chunk, sandbox)
|
||||
if not pcall(chunk) then return nil end
|
||||
if type(sandbox.pfUI_config) == "table" then return sandbox.pfUI_config end
|
||||
return nil
|
||||
end
|
||||
|
||||
do -- Window
|
||||
@@ -309,33 +234,43 @@ pfUI:RegisterModule("share", function ()
|
||||
this:GetParent():UpdateScrollChildRect()
|
||||
this:GetParent():UpdateScrollState()
|
||||
|
||||
local _, error = loadstring(f.scroll.text:GetText())
|
||||
if error or string.gsub(this:GetText(), " ", "") == "" then
|
||||
f.loadButton:Disable()
|
||||
f.loadButton.text:SetTextColor(1,.5,.5,1)
|
||||
else
|
||||
local text = this:GetText()
|
||||
local blob = Decode(text)
|
||||
local json = not blob and DecodeJSON(text) or nil
|
||||
local legacy = not blob and not json and DecodeLegacy(text) or nil
|
||||
|
||||
if blob or json or legacy then
|
||||
f.loadButton:Enable()
|
||||
f.loadButton.text:SetTextColor(.5,1,.5,1)
|
||||
else
|
||||
f.loadButton:Disable()
|
||||
f.loadButton.text:SetTextColor(1,.5,.5,1)
|
||||
end
|
||||
|
||||
local trydec = dec(this:GetText())
|
||||
if string.gsub(this:GetText(), " ", "") == "" then
|
||||
f.readButton.text:SetText(T["N/A"])
|
||||
f.readButton:Disable()
|
||||
elseif not trydec or trydec == "" then
|
||||
f.readButton:Enable()
|
||||
f.readButton.text:SetText(T["Encode"])
|
||||
f.readButton.func = function()
|
||||
local compressed = enc(compress(f.scroll.text:GetText()))
|
||||
f.scroll.text:SetText(compressed)
|
||||
end
|
||||
else
|
||||
if blob or legacy then
|
||||
-- decoding a legacy string yields the JSON view; re-encoding it
|
||||
-- from there produces a new-format blob (migration path)
|
||||
f.readButton:Enable()
|
||||
f.readButton.text:SetText(T["Decode"])
|
||||
f.readButton.func = function()
|
||||
local uncompressed = decompress(dec(f.scroll.text:GetText()))
|
||||
f.scroll.text:SetText(uncompressed)
|
||||
local current = f.scroll.text:GetText()
|
||||
local config = Decode(current) or DecodeLegacy(current)
|
||||
if config then
|
||||
f.scroll.text:SetText(C_EncodingUtil.SerializeJSON(config))
|
||||
end
|
||||
end
|
||||
elseif json then
|
||||
f.readButton:Enable()
|
||||
f.readButton.text:SetText(T["Encode"])
|
||||
f.readButton.func = function()
|
||||
local config = DecodeJSON(f.scroll.text:GetText())
|
||||
if config then
|
||||
f.scroll.text:SetText(Encode(config))
|
||||
end
|
||||
end
|
||||
else
|
||||
f.readButton:Disable()
|
||||
f.readButton.text:SetText(T["N/A"])
|
||||
end
|
||||
end)
|
||||
f.scroll:SetScrollChild(f.scroll.text)
|
||||
@@ -387,21 +322,23 @@ pfUI:RegisterModule("share", function ()
|
||||
f.loadButton.text:SetFont(pfUI.font_default, pfUI_config.global.font_size, "OUTLINE")
|
||||
f.loadButton.text:SetText(T["Import"])
|
||||
f.loadButton:SetScript("OnClick", function()
|
||||
local ImportConfig, error = loadstring(f.scroll.text:GetText())
|
||||
if not error and f.scroll.text:GetText() ~= "" then
|
||||
ImportConfig()
|
||||
pfUI:LoadConfig()
|
||||
local text = f.scroll.text:GetText()
|
||||
local config = Decode(text) or DecodeJSON(text) or DecodeLegacy(text)
|
||||
if not config then return end
|
||||
|
||||
-- Skip firstrun wizard when importing a shared profile
|
||||
-- The imported config is a complete setup, no wizard needed
|
||||
if pfUI.firstrun and pfUI.firstrun.steps then
|
||||
for _, step in pairs(pfUI.firstrun.steps) do
|
||||
pfUI_init[step.name] = true
|
||||
end
|
||||
_G.pfUI_config = config
|
||||
C = _G.pfUI_config
|
||||
pfUI:LoadConfig()
|
||||
|
||||
-- Skip firstrun wizard when importing a shared profile
|
||||
-- The imported config is a complete setup, no wizard needed
|
||||
if pfUI.firstrun and pfUI.firstrun.steps then
|
||||
for _, step in pairs(pfUI.firstrun.steps) do
|
||||
pfUI_init[step.name] = true
|
||||
end
|
||||
|
||||
CreateQuestionDialog(T["Some settings need to reload the UI to take effect.\nDo you want to reloadUI now?"], ReloadUI)
|
||||
end
|
||||
|
||||
CreateQuestionDialog(T["Some settings need to reload the UI to take effect.\nDo you want to reloadUI now?"], ReloadUI)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -445,9 +382,9 @@ pfUI:RegisterModule("share", function ()
|
||||
ignored["position"] = f.ignorePosition:GetChecked()
|
||||
ignored["disabled"] = f.ignorePosition:GetChecked()
|
||||
|
||||
local compressed = enc(compress(serialize(myconfig, defconfig, "pfUI_config", ignored)))
|
||||
f.scroll.text:SetText(compressed)
|
||||
f.scroll.text.value = compressed
|
||||
local encoded = Encode(DiffConfig(myconfig, defconfig, ignored) or {})
|
||||
f.scroll.text:SetText(encoded)
|
||||
f.scroll.text.value = encoded
|
||||
f.scroll:SetVerticalScroll(0)
|
||||
end)
|
||||
end
|
||||
@@ -457,4 +394,4 @@ pfUI:RegisterModule("share", function ()
|
||||
f:Show()
|
||||
end
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
Reference in New Issue
Block a user