mirror of
https://www.gitlink.org.cn/mayx/mayx.gitlink.net
synced 2026-10-08 04:51:55 +08:00
Compare commits
No commits in common. "master" and "AR-Backup-2026.10.01" have entirely different histories.
master
...
AR-Backup-
|
|
@ -17,6 +17,7 @@ mirrors:
|
|||
- https://mayx.vercel.app/
|
||||
- https://mayx.netlify.app/
|
||||
- https://mayx.gitnet.page/
|
||||
- https://mayx.stormkit.dev/
|
||||
- https://mayx.grebedoc.dev/
|
||||
- https://mayx.codeberg.page/
|
||||
- https://mayx.tildepages.org/
|
||||
|
|
|
|||
|
|
@ -98,8 +98,7 @@ layout: xslt_container
|
|||
<li><a href="{{ site.github.tar_url }}">Download <strong>TAR Ball</strong></a></li>
|
||||
<li><a href="{{ site.github.repository_url }}">View On <strong>GitHub</strong></a></li>
|
||||
{% else %}
|
||||
<li style="width: 134px;"><a href="/MayxBlog.7z">Download <strong>7-Zip File</strong></a></li>
|
||||
<li style="width: 134px; border-right: none;"><a href="/offline.html">Manage <strong>Offline Mode</strong></a></li>
|
||||
<li style="width: 270px; border-right: none;"><a href="/MayxBlog.7z">Download <strong>7-Zip File</strong></a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ tags: [Mayx,MOS]
|
|||
MOS是一个用来解释我不能解释的东西的一个代替品,就如同UFO和人们定义的神一样,因为未知,而又需要一种解释的方法,于是出现了MOS。
|
||||
***
|
||||
## 正文
|
||||

|
||||

|
||||
```
|
||||
MOS Log System has been Load.
|
||||
MOS Analysis System is Ready.
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ Tel +34 612 520330
|
|||
|
||||
然后还非常用心的配了一张快递单:
|
||||
|
||||

|
||||

|
||||
|
||||
注:由于隐私原因,涉及隐私的地方已经被删除 ~~(尽管大家都知道我的手机号)~~
|
||||
这个邮件当时也没细看,然后就给Lori Robinson说了我已经给他们发了我的信息,过了几天,她给我发:
|
||||
|
|
|
|||
|
|
@ -190,568 +190,6 @@ class BlogQuine:
|
|||
|
||||
于是我第一时间就把原来的TGZ压缩命令换掉,换成了AI给我写的[blogquine.py](https://github.com/Mabbs/mabbs.github.io/blob/master/_tools/blogquine.py),现在就可以通过[这里](https://mayx.eu.org/MayxBlog.7z)下载到“完整”包含我博客所有内容的压缩包了。
|
||||
不过唯一的问题就是这样做出来的压缩包并没有压缩😂,相当于给做成了普通的归档了。当然我的博客本身倒是不大,没压缩也多不了多少空间,但相比于能做出“完整”的效果来说,这点浪费的空间也是小问题了。
|
||||
## 基于博客压缩包的离线模式(2026.10.03更新)
|
||||
既然现在的AI什么都能做了,那上次我因为技术不足仅仅只是用[Service Worker做了个代理](/2025/08/01/sw-proxy.html),现在也可以升级成下载全站之后让全站离线的功能吧?不过之前做的压缩包并没有压缩,导致整个压缩包有接近20MiB的大小,后来我仔细想了一下我的需求,我只是想让下载全站压缩包的按钮不断链,并没有说解压后的文件一定就必须是它本身,所以我只要用相同的文件名再压缩一次应该就可以了,这样既能满足我的想法,又可以让其他人下载的时候不会花费太多的时间。
|
||||
在我直接压缩之后,整个压缩包占用大概9MiB的空间,其实我已经很满意了,不过压之前是18MiB,也就是说压缩率是50%……虽然也不是不能接受,但我在想这是不是合理的结果呢?我让AI分析了一下50%的压缩率正不正常,它看了一眼之后告诉我文本部分的10MiB压缩完之后有1MiB,图片因为压不动所以直接占用了8MiB😅,这时候我才意识到原来我一直存着一堆占着大量空间的垃圾,而且还是用的PNG/JPG这种压缩率很低的格式……考虑到兼容性和压缩效果,我把占用比较大的图片转换为了WebP格式,最终整个压缩包就只占用了4MiB的空间,一口气又节省了50%的空间。
|
||||
最后做完对压缩包的处理之后,我直接让AI给我基于那个压缩包制作离线模式,它轻轻松松就[做出来](https://mayx.eu.org/offline.html)了,最终使用的是[libarchive库](https://github.com/nika-begiashvili/libarchivejs),在启用离线模式之后整个网站的速度简直就是0延迟,点击就能瞬间跳转,效果算是相当不错了。
|
||||
## 受大佬启发制作了能压缩的7z Quine(2026.10.05更新)
|
||||
做完博客压缩包之后我突发奇想,虽然东西是AI做的,但也是在我的手中做出了世界上第一个LZMA2 Quine,那么如果我向全世界第一个制作ZIP Quine的人展示一下我的作品会怎么样呢?目前可以知道,世界上第一个制作ZIP Quine的人是[Erling Ellingsen](https://infosec.exchange/@steike),所以我向他发了个帖子[展示了一下](https://infosec.exchange/@mayx/117385801954308033)。大佬不愧是大佬,一眼就能看出理论上能压缩的7z Quine是可以做出来的,对我来说,我是完全不知道7-Zip居然还支持链式编解码,能可以通过“double lzma2”的方式做到既可以自包含、又能真正被压缩的压缩包。既然大佬说理论上可以,那我自然是相信的,于是就让AI给我使劲折腾想办法把它做出来。在折腾了一晚上之后AI终于做出来了,真是强得可怕:
|
||||
```python
|
||||
# ============================================================
|
||||
# 双层 LZMA2 的 7z quine("double lzma2")
|
||||
#
|
||||
# F = S32 ‖ P ‖ H,folder 的 coder 链 = [LZMA2(外层), LZMA2(内层)],
|
||||
# 解码管线:P --外层--> I --内层--> U。
|
||||
#
|
||||
# I = Cseg ‖ LBseg ‖ Mseg ‖ 0x00
|
||||
# Cseg : 真实 LZMA2 对 content 的压缩流(内容真正的压缩数据 C)
|
||||
# LBseg: 内层 store 链,载荷 = seed(seed 内含一份 C 的参照副本)
|
||||
# Mseg : 内层 match 链,输出 quine 区(= F 的逐字节副本)
|
||||
# U = content ‖ seed ‖ F
|
||||
#
|
||||
# 外层把 I 再压一遍:Cseg 用 store 链原样承载;seed 里的 C 参照副本与 Cseg
|
||||
# 逐字节相同,被外层的 rep0 回溯匹配消掉,于是成品里只剩一份压缩数据。
|
||||
# quine 区回引 P 的字节时,源全在 seed 里(C 副本 / 头样本表 / 机械串)。
|
||||
# ============================================================
|
||||
def lzma_chunk(tokens, out_pos):
|
||||
"""编一个 LZMA chunk(0xC0: state+props reset,无 dict reset,无 end marker)。
|
||||
match token 的编码字节只取决于 (dist,len,pos),与历史内容无关(不维护输出历史)。
|
||||
解压长度用 21 位(控制字节低 5 位存高 5 位),故单 chunk 输出上限 2 MiB。"""
|
||||
enc = LzmaEncoder()
|
||||
enc.pos = out_pos
|
||||
total = 0
|
||||
for t in tokens:
|
||||
if t[0] == "m":
|
||||
enc.match(t[1], t[2])
|
||||
total += t[2]
|
||||
else:
|
||||
enc.rep_match(t[1])
|
||||
total += t[1]
|
||||
data = enc.finish()
|
||||
u = total - 1
|
||||
c = len(data) - 1
|
||||
assert 0 <= u < (1 << 21) and 0 <= c < 65536, (total, len(data))
|
||||
hdr = bytes([0xC0 | ((u >> 16) & 0x1F)]) + (u & 0xFFFF).to_bytes(2, "big") + \
|
||||
(c & 0xFFFF).to_bytes(2, "big") + bytes([PROPS_BYTE])
|
||||
return hdr + data, total
|
||||
|
||||
LZ2_MAXU = 1 << 21 # LZMA2 单个 LZMA chunk 的解压上限
|
||||
|
||||
|
||||
def lzma2_raw(data, dict_size, preset=6):
|
||||
"""FORMAT_RAW + LZMA2 压缩;去掉结尾 0x00 便于后面继续追加 chunk。"""
|
||||
c = lzma.LZMACompressor(
|
||||
format=lzma.FORMAT_RAW,
|
||||
filters=[{"id": lzma.FILTER_LZMA2, "dict_size": dict_size, "preset": preset}])
|
||||
out = c.compress(data) + c.flush()
|
||||
assert out and out[-1] == 0x00, "LZMA2 流应以 0x00 结尾"
|
||||
return out[:-1]
|
||||
|
||||
|
||||
def split_len(n, cap):
|
||||
"""按 cap 上限切分 n 字节。末片若为 1 字节则从前一片借 1(match 最短为 2)。"""
|
||||
Ls, rem = [], n
|
||||
while rem > cap:
|
||||
Ls.append(cap)
|
||||
rem -= cap
|
||||
if rem == 1 and Ls:
|
||||
Ls[-1] -= 1
|
||||
rem = 2
|
||||
Ls.append(rem)
|
||||
return Ls
|
||||
|
||||
|
||||
class DoubleQuine(BlogQuine):
|
||||
"""双层 LZMA2 版本:folder = [LZMA2(外层), LZMA2(内层)]。"""
|
||||
|
||||
def entries(self):
|
||||
"""U 的顺序是 内容文件… ‖ seed ‖ quine,条目顺序必须与之对应。"""
|
||||
ent = [{"name": r, "dir": isd} for r, isd in self.walk_entries]
|
||||
ent.append({"name": self.seed_name, "dir": False})
|
||||
ent.append({"name": self.quine_name, "dir": False})
|
||||
return ent
|
||||
|
||||
# ---------- 头部:双 coder + BindPair ----------
|
||||
def build_header(self, n, total, d, entries, sub_sizes, size_I):
|
||||
n_sub = len(sub_sizes)
|
||||
N = len(entries)
|
||||
h = bytearray()
|
||||
h += b"\x01\x04" # kHeader, kMainStreamsInfo
|
||||
h += b"\x06" + varint(0) + varint(1) + b"\x09" + varint(n) + b"\x00"
|
||||
h += b"\x07" # kUnpackInfo
|
||||
h += b"\x0b" + varint(1) + b"\x00" # kFolder: 1, local
|
||||
h += varint(2) # NumCoders = 2
|
||||
h += b"\x21\x21" + varint(1) + bytes([self.dict_prop_out]) # coder0 = 外层
|
||||
h += b"\x21\x21" + varint(1) + bytes([self.dict_prop_in]) # coder1 = 内层
|
||||
h += varint(1) + varint(0) # BindPair = (InIndex=1, OutIndex=0)
|
||||
h += b"\x0c" + varint(size_I) + varint(d + total) # 两个 coder 的输出大小
|
||||
h += b"\x00"
|
||||
h += b"\x08" # SubStreamsInfo
|
||||
h += b"\x0d" + varint(n_sub)
|
||||
h += b"\x09" + b"".join(varint(s) for s in sub_sizes[:-1])
|
||||
h += b"\x0a" + b"\x01"
|
||||
crc_base = len(h)
|
||||
h += b"\x00" * (4 * n_sub)
|
||||
h += b"\x00\x00"
|
||||
h += b"\x05" + varint(N) # FilesInfo
|
||||
bits = bytearray((N + 7) // 8)
|
||||
for i, e in enumerate(entries):
|
||||
if e["dir"]:
|
||||
bits[i // 8] |= 1 << (7 - i % 8)
|
||||
h += b"\x0e" + varint(len(bits)) + bytes(bits)
|
||||
mt = b"\x01\x00" + struct.pack("<Q", self.mtime_ft) * N
|
||||
h += b"\x14" + varint(len(mt)) + mt
|
||||
names = bytearray(b"\x00")
|
||||
for e in entries:
|
||||
names += e["name"].encode("utf-16-le") + b"\x00\x00"
|
||||
h += b"\x11" + varint(len(names)) + bytes(names)
|
||||
attrs = b"".join(struct.pack("<I", 0x10 if e["dir"] else 0x20)
|
||||
for e in entries)
|
||||
h += b"\x15" + varint(2 + len(attrs)) + b"\x01\x00" + attrs
|
||||
h += b"\x00\x00"
|
||||
return bytes(h), crc_base
|
||||
|
||||
# ---------- 布局:三层坐标的不动点迭代 ----------
|
||||
def layout(self):
|
||||
content = self.content
|
||||
dc = len(content)
|
||||
dict_c = max(1 << 20, 1 << max(20, dc.bit_length()))
|
||||
C = lzma2_raw(content, dict_c)
|
||||
self._C = C # 内容压缩只做一次,finalize 复用
|
||||
lc = len(C)
|
||||
# 迭代变量:Y = plant 长度, H = 头部长度, M = Mseg 字节数
|
||||
Y, H, M = 96, 256, 256
|
||||
seen = set()
|
||||
for _ in range(200):
|
||||
ds = lc + 32 + H + Y # seed = C副本‖S32‖H‖plant
|
||||
d = dc + ds
|
||||
Ls_LB = split_len(ds, CHUNK)
|
||||
lLB = 3 * len(Ls_LB) + ds
|
||||
off_in = lc + lLB
|
||||
M1 = M + 1 # Mseg ‖ 内层流终止符 0x00
|
||||
Ls_M = split_len(M1, CHUNK)
|
||||
Li = off_in + M1
|
||||
self.dict_prop_out, _ = dict_prop_for(max(Li, 1 << 20))
|
||||
self.dict_prop_in, _ = dict_prop_for(d + 32 + H + 8192)
|
||||
st = self._build_streams(C, lc, ds, d, Ls_LB, lLB, off_in, Ls_M,
|
||||
M1, H)
|
||||
if st is None:
|
||||
Y += 8
|
||||
continue
|
||||
mseg = self._build_mseg(st, d, lc, ds, H, M)
|
||||
if mseg is None:
|
||||
Y += 8
|
||||
continue
|
||||
M_new = len(mseg)
|
||||
Y_new = len(st["hdr_bytes"]) + len(st["mech"]) + M_new + 2
|
||||
total = st["n"] + 32 + H
|
||||
file_sizes = [len(x) for _, x in self.files]
|
||||
sub_sizes = file_sizes + [ds] + [total]
|
||||
entries = self.entries()
|
||||
hdr, crc_base = self.build_header(st["n"], total, d, entries,
|
||||
sub_sizes, Li)
|
||||
H_new = len(hdr)
|
||||
key = (Y_new, H_new, M_new)
|
||||
if key in seen: # 再次见到同一组值 = 已收敛
|
||||
Y, H, M = Y_new, H_new, M_new
|
||||
break
|
||||
seen.add(key)
|
||||
Y, H, M = Y_new, H_new, M_new
|
||||
else:
|
||||
raise RuntimeError("double layout 不收敛")
|
||||
return {"Y": Y, "H": H, "M": M}
|
||||
|
||||
# ---------- 定稿:用收敛后的 (Y, H, M) 重算全部结构 ----------
|
||||
def finalize(self, Y, H, M):
|
||||
content = self.content
|
||||
dc = len(content)
|
||||
C = self._C # layout 里已压缩过
|
||||
lc = len(C)
|
||||
ds = lc + 32 + H + Y
|
||||
d = dc + ds
|
||||
Ls_LB = split_len(ds, CHUNK)
|
||||
lLB = 3 * len(Ls_LB) + ds
|
||||
off_in = lc + lLB
|
||||
M1 = M + 1
|
||||
Ls_M = split_len(M1, CHUNK)
|
||||
Li = off_in + M1
|
||||
self.dict_prop_out, ds_out = dict_prop_for(max(Li, 1 << 20))
|
||||
self.dict_prop_in, ds_in = dict_prop_for(d + 32 + H + 8192)
|
||||
st = self._build_streams(C, lc, ds, d, Ls_LB, lLB, off_in, Ls_M, M1, H)
|
||||
mseg = self._build_mseg(st, d, lc, ds, H, M)
|
||||
assert len(mseg) == M, "Mseg %d != %d" % (len(mseg), M)
|
||||
total = st["n"] + 32 + H
|
||||
file_sizes = [len(x) for _, x in self.files]
|
||||
sub_sizes = file_sizes + [ds] + [total]
|
||||
entries = self.entries()
|
||||
hdr, crc_base = self.build_header(st["n"], total, d, entries,
|
||||
sub_sizes, Li)
|
||||
assert len(hdr) == H, "header %d != %d" % (len(hdr), H)
|
||||
plant = st["hdr_bytes"] + bytes(st["mech"]) + mseg + b"\x00\x00"
|
||||
assert len(plant) == Y, "plant %d != %d" % (len(plant), Y)
|
||||
return {"C": C, "lc": lc, "dc": dc, "ds": ds, "d": d, "Li": Li,
|
||||
"ochunks": st["ochunks"], "hdr_tab": st["hdr_tab"],
|
||||
"mseg": mseg, "M": M, "H": H, "n": st["n"], "total": total,
|
||||
"header": hdr, "crc_base": crc_base, "plant": plant,
|
||||
"dict_size_in": ds_in, "dict_size_out": ds_out,
|
||||
"n_sub": len(sub_sizes)}
|
||||
|
||||
# ---------- 外层流 P 的构造 ----------
|
||||
def _build_streams(self, C, lc, ds, d, Ls_LB, lLB, off_in, Ls_M, M1, H):
|
||||
hdr_tab, hdr_idx = [], {}
|
||||
|
||||
def hidx(b):
|
||||
assert len(b) == 3 and b[0] in (0x01, 0x02), b.hex()
|
||||
if b not in hdr_idx:
|
||||
hdr_idx[b] = len(hdr_tab)
|
||||
hdr_tab.append(b)
|
||||
return hdr_idx[b]
|
||||
|
||||
ochunks = []
|
||||
f, io = 32, 0
|
||||
mech = bytearray()
|
||||
|
||||
def put_lzma(tokens, ioff):
|
||||
blk, t = lzma_chunk(tokens, ioff)
|
||||
ochunks.append({"kind": "lzma", "foff": f, "size": len(blk),
|
||||
"ioff": ioff, "olen": t, "bytes": blk,
|
||||
"mech": len(mech)})
|
||||
mech.extend(blk)
|
||||
return len(blk), t
|
||||
|
||||
def put_store(ioff, olen, hi, src):
|
||||
ochunks.append({"kind": "store", "foff": f, "size": 3 + olen,
|
||||
"ioff": ioff, "olen": olen, "hdr": hi, "src": src})
|
||||
return 3 + olen
|
||||
|
||||
# 段 1:store 链承载 Cseg(I[0:lc] = C 的字节;源 = seed 的 C 参照副本)
|
||||
Ls_C = split_len(lc, CHUNK)
|
||||
c_off = 0
|
||||
for L in Ls_C:
|
||||
f += put_store(io, L, hidx(store_hdr(L, first=(c_off == 0))),
|
||||
("seed", c_off))
|
||||
io += L
|
||||
c_off += L
|
||||
assert io == lc
|
||||
|
||||
# 段 2:LBseg(I[lc : lc+lLB])—— 内层 store 链,载荷 = seed
|
||||
lb_off = lc
|
||||
seed_off = 0
|
||||
for j, L in enumerate(Ls_LB):
|
||||
lb_hdr = store_hdr(L, first=False)
|
||||
if j == 0 or L != CHUNK:
|
||||
# 没有可回引的相同样本,用外层 store chunk 原样携带这 3 字节
|
||||
f += put_store(lb_off, 3, hidx(store_hdr(3, first=False)),
|
||||
("tab", hidx(lb_hdr)))
|
||||
io += 3
|
||||
else:
|
||||
# 与 chunk0 的头逐字节相同,直接从 I[lc:lc+3] 复制
|
||||
n, t = put_lzma([("m", lb_off - lc, 3)], lb_off)
|
||||
assert t == 3
|
||||
f += n
|
||||
io += 3
|
||||
# 载荷:C 参照副本区走 rep0 回溯匹配(去重发生处),其余走 store
|
||||
pay = lb_off + 3
|
||||
i = 0
|
||||
while i < L:
|
||||
if seed_off + i < lc:
|
||||
a = min(L - i, lc - (seed_off + i), LZ2_MAXU)
|
||||
dist = (pay + i) - (seed_off + i)
|
||||
n, t = put_lzma(matches_for(dist, a), pay + i)
|
||||
assert t == a
|
||||
f += n
|
||||
io += a
|
||||
i += a
|
||||
else:
|
||||
a = min(L - i, LZ2_MAXU)
|
||||
f += put_store(pay + i, a, hidx(store_hdr(a, first=False)),
|
||||
("seed", seed_off + i))
|
||||
io += a
|
||||
i += a
|
||||
lb_off += 3 + L
|
||||
seed_off += L
|
||||
assert lb_off == lc + lLB and io == lc + lLB
|
||||
|
||||
# 段 3:store 链承载 Mseg ‖ 0x00(0x00 是内层流的终止符)
|
||||
m_off = 0
|
||||
for L in Ls_M:
|
||||
f += put_store(off_in + m_off, L,
|
||||
hidx(store_hdr(L, first=False)), ("mseg", m_off))
|
||||
io += L
|
||||
m_off += L
|
||||
# 外层流自己的终止符 0x00
|
||||
ochunks.append({"kind": "term", "foff": f, "size": 1, "ioff": io,
|
||||
"olen": 0, "bytes": b"\x00"})
|
||||
f += 1
|
||||
return {"ochunks": ochunks, "mech": mech, "hdr_tab": hdr_tab,
|
||||
"hdr_bytes": b"".join(hdr_tab), "n": f - 32}
|
||||
|
||||
# ---------- 内层 Mseg:把 quine 区的每个 F 区段映射成 match 链 ----------
|
||||
def _build_mseg(self, st, d, lc, ds, H, M):
|
||||
dc = d - ds
|
||||
s_S32 = dc + lc # seed 里 S32 副本
|
||||
s_H = dc + lc + 32 # seed 里 H 副本
|
||||
s_tab = dc + lc + 32 + H # seed 里头样本表
|
||||
s_mech = s_tab + len(st["hdr_bytes"]) # seed 里机械串
|
||||
s_mseg = s_mech + len(st["mech"]) # seed 里 Mseg 副本
|
||||
total = st["n"] + 32 + H
|
||||
hoff = 32 + st["n"]
|
||||
segs = [(0, 32, s_S32)] # F[0:32) 签名头
|
||||
for c in st["ochunks"]:
|
||||
if c["kind"] == "store":
|
||||
segs.append((c["foff"], 3, s_tab + 3 * c["hdr"]))
|
||||
src = c["src"]
|
||||
if src[0] == "seed":
|
||||
u = dc + src[1]
|
||||
elif src[0] == "tab":
|
||||
u = s_tab + 3 * src[1]
|
||||
else: # ("mseg", off)
|
||||
u = s_mseg + src[1]
|
||||
segs.append((c["foff"] + 3, c["olen"], u))
|
||||
elif c["kind"] == "lzma":
|
||||
segs.append((c["foff"], c["size"], s_mech + c["mech"]))
|
||||
else: # term:并入前一个载荷段
|
||||
pf, pl, pu = segs[-1]
|
||||
segs[-1] = (pf, pl + 1, pu)
|
||||
segs.append((hoff, H, s_H)) # F[hoff:] 头部
|
||||
pos = 0
|
||||
for (fo, ln, u) in segs:
|
||||
assert fo == pos, "段不连续: %d != %d" % (fo, pos)
|
||||
assert ln >= 2, "段太短 %d @%d" % (ln, fo)
|
||||
pos += ln
|
||||
assert pos == total, "段总长 %d != total %d" % (pos, total)
|
||||
# 生成 match 链,按 2 MiB 上限切成多个 0xC0 chunk
|
||||
mseg = bytearray()
|
||||
out_pos = d
|
||||
cur, cur_len = [], 0
|
||||
chunks = []
|
||||
for (fo, ln, u) in segs:
|
||||
dist = (d + fo) - u
|
||||
assert 0 < dist, "dist=%d @%d" % (dist, fo)
|
||||
toks = matches_for(dist, ln)
|
||||
if cur_len + ln > LZ2_MAXU - MAX_MATCH:
|
||||
chunks.append((cur, out_pos, cur_len))
|
||||
out_pos += cur_len
|
||||
cur, cur_len = [], 0
|
||||
cur.extend(toks)
|
||||
cur_len += ln
|
||||
if cur:
|
||||
chunks.append((cur, out_pos, cur_len))
|
||||
out_pos += cur_len
|
||||
assert out_pos == d + total, "Mseg 输出 %d != %d" % (out_pos, d + total)
|
||||
for (toks, op, cl) in chunks:
|
||||
blk, t = lzma_chunk(toks, op)
|
||||
assert t == cl
|
||||
mseg.extend(blk)
|
||||
return bytes(mseg)
|
||||
|
||||
# ---------- F 中承载 seed[soff] 的那个字节的偏移 ----------
|
||||
def _f_off_of_seed(self, lay, soff):
|
||||
"""返回 (F 偏移, 该 store 载荷内从 soff 起还剩多少字节)。
|
||||
seed_head 超过 64 KiB 时会被切成多片,所以调用方要循环推进。"""
|
||||
for c in lay["ochunks"]:
|
||||
if c["kind"] == "store" and c["src"][0] == "seed":
|
||||
s0 = c["src"][1]
|
||||
if s0 <= soff < s0 + c["olen"]:
|
||||
return c["foff"] + 3 + (soff - s0), s0 + c["olen"] - soff
|
||||
raise RuntimeError("seed 偏移 %d 未被 store 载荷覆盖" % soff)
|
||||
|
||||
def _f_pos_list(self, lay, soff, length):
|
||||
"""seed[soff:soff+length] 在 F 中对应的字节位置列表(可能跨多片)。"""
|
||||
res = []
|
||||
while length > 0:
|
||||
p, avail = self._f_off_of_seed(lay, soff)
|
||||
a = min(length, avail)
|
||||
res.extend(range(p, p + a))
|
||||
soff += a
|
||||
length -= a
|
||||
return res
|
||||
|
||||
# ---------- 装配 ----------
|
||||
def assemble(self, lay):
|
||||
total, H = lay["total"], lay["H"]
|
||||
hoff = total - H
|
||||
C, plant = lay["C"], lay["plant"]
|
||||
F = bytearray(total)
|
||||
# pass A: 签名头 + 所有非 store 载荷字节
|
||||
F[0:6] = SIG
|
||||
F[6:8] = VER
|
||||
struct.pack_into("<Q", F, 12, lay["n"])
|
||||
struct.pack_into("<Q", F, 20, H)
|
||||
for c in lay["ochunks"]:
|
||||
if c["kind"] == "store":
|
||||
F[c["foff"]:c["foff"] + 3] = lay["hdr_tab"][c["hdr"]]
|
||||
else:
|
||||
F[c["foff"]:c["foff"] + c["size"]] = c["bytes"]
|
||||
F[hoff:hoff + H] = lay["header"]
|
||||
# pass B: seed = C副本 ‖ S32副本 ‖ H副本 ‖ plant
|
||||
seed = bytearray()
|
||||
seed += C
|
||||
seed += bytes(F[0:32])
|
||||
seed += bytes(F[hoff:hoff + H])
|
||||
seed += plant
|
||||
assert len(seed) == lay["ds"], "seed %d != %d" % (len(seed), lay["ds"])
|
||||
# pass C: 各 store 的载荷
|
||||
msegz = lay["mseg"] + b"\x00"
|
||||
for c in lay["ochunks"]:
|
||||
if c["kind"] != "store":
|
||||
continue
|
||||
p = c["foff"] + 3
|
||||
src, L = c["src"], c["olen"]
|
||||
if src[0] == "seed":
|
||||
F[p:p + L] = seed[src[1]:src[1] + L]
|
||||
elif src[0] == "tab":
|
||||
F[p:p + 3] = lay["hdr_tab"][src[1]]
|
||||
else:
|
||||
F[p:p + L] = msegz[src[1]:src[1] + L]
|
||||
return F, bytes(seed)
|
||||
|
||||
# ---------- CRC 定点(128x128 GF(2):seed/quine/Next/Start 四个 CRC) ----------
|
||||
def solve_crc(self, F, lay, C, plant):
|
||||
H, hoff = lay["H"], lay["total"] - lay["H"]
|
||||
n_sub, crc_base = lay["n_sub"], lay["crc_base"]
|
||||
lc = lay["lc"]
|
||||
# seed 里 S32 / H 副本在 F 中的镜像(可能跨多片,逐字节列出)
|
||||
scopy0 = self._f_pos_list(lay, lc, 32)
|
||||
Fv = memoryview(F)
|
||||
|
||||
def seed_crc():
|
||||
v = crc32(C)
|
||||
v = crc32(Fv[0:32], v) # S32 副本
|
||||
v = crc32(Fv[hoff:hoff + H], v) # H 副本
|
||||
return crc32(plant, v)
|
||||
|
||||
def targets():
|
||||
return (seed_crc(), crc32(Fv), crc32(Fv[hoff:hoff + H]),
|
||||
crc32(Fv[12:32]))
|
||||
|
||||
# 已知:各内容文件的 CRC,写本体 + seed 里 H 副本的镜像两处
|
||||
known = [crc32(data) for _, data in self.files]
|
||||
for i, e in enumerate(known):
|
||||
slot = crc_base + 4 * i
|
||||
b = struct.pack("<I", e)
|
||||
for k in range(4):
|
||||
F[hoff + slot + k] = b[k]
|
||||
mir = self._f_pos_list(lay, lc + 32 + slot, 4)
|
||||
assert len(mir) == 4
|
||||
for k in range(4):
|
||||
F[mir[k]] = b[k]
|
||||
# 未知量:D_seed(seed 的 CRC)、D(quine 的 CRC)、N、S
|
||||
# 每个未知量 4 字节,每字节有"本体 + seed 镜像"两个落点
|
||||
qs = crc_base + 4 * len(known) # seed 子流的 CRC 槽
|
||||
q = crc_base + 4 * (n_sub - 1) # quine 子流的 CRC 槽
|
||||
groups = []
|
||||
for slot in (qs, q):
|
||||
groups.append([list(range(hoff + slot, hoff + slot + 4)),
|
||||
self._f_pos_list(lay, lc + 32 + slot, 4)])
|
||||
groups.append([list(range(28, 32)), scopy0[28:32]]) # NextHeaderCRC
|
||||
groups.append([list(range(8, 12)), scopy0[8:12]]) # StartHeaderCRC
|
||||
nb = 32 * len(groups)
|
||||
base = targets()
|
||||
basevec = 0
|
||||
for i, v in enumerate(base):
|
||||
basevec |= v << (32 * i)
|
||||
cols = []
|
||||
for g in range(len(groups)):
|
||||
own, mir = groups[g]
|
||||
for kb in range(32):
|
||||
bi, bb = kb // 8, kb % 8
|
||||
poss = [own[bi], mir[bi]]
|
||||
for p in poss:
|
||||
F[p] ^= (1 << bb)
|
||||
t = targets()
|
||||
for p in poss:
|
||||
F[p] ^= (1 << bb)
|
||||
delta = 0
|
||||
for i, v in enumerate(t):
|
||||
delta |= (v ^ base[i]) << (32 * i)
|
||||
cols.append(delta)
|
||||
eqs = []
|
||||
for i in range(nb):
|
||||
coeff = 0
|
||||
for j in range(nb):
|
||||
if (cols[j] >> i) & 1:
|
||||
coeff |= 1 << j
|
||||
coeff ^= 1 << i
|
||||
eqs.append([coeff, (basevec >> i) & 1])
|
||||
for j in range(nb):
|
||||
p = next((i for i in range(j, nb) if (eqs[i][0] >> j) & 1), None)
|
||||
if p is None:
|
||||
raise RuntimeError("GF(2) 奇异 @bit%d" % j)
|
||||
eqs[j], eqs[p] = eqs[p], eqs[j]
|
||||
for i in range(nb):
|
||||
if i != j and ((eqs[i][0] >> j) & 1):
|
||||
eqs[i][0] ^= eqs[j][0]
|
||||
eqs[i][1] ^= eqs[j][1]
|
||||
x = 0
|
||||
for i in range(nb):
|
||||
assert eqs[i][0] == (1 << i)
|
||||
if eqs[i][1]:
|
||||
x |= 1 << i
|
||||
vals = [(x >> (32 * i)) & 0xFFFFFFFF for i in range(len(groups))]
|
||||
for g in range(len(groups)):
|
||||
own, mir = groups[g]
|
||||
b = struct.pack("<I", vals[g])
|
||||
for k in range(4):
|
||||
F[own[k]] = b[k]
|
||||
F[mir[k]] = b[k]
|
||||
t = targets()
|
||||
assert t == tuple(vals), "CRC 定点失败: %s != %s" % (t, vals)
|
||||
return vals
|
||||
|
||||
def build(self):
|
||||
l0 = self.layout()
|
||||
lay = self.finalize(l0["Y"], l0["H"], l0["M"])
|
||||
F, seed = self.assemble(lay)
|
||||
crcs = self.solve_crc(F, lay, lay["C"], lay["plant"])
|
||||
# CRC 定点会改写 H / S32,而 seed 是它们的镜像,定点后需重建
|
||||
hoff = lay["total"] - lay["H"]
|
||||
seed = lay["C"] + bytes(F[0:32]) + bytes(F[hoff:hoff + lay["H"]]) \
|
||||
+ lay["plant"]
|
||||
return bytes(F), lay, seed, crcs
|
||||
|
||||
|
||||
def verify_double(F, lay, files, seed, content):
|
||||
"""串联两个原始 LZMA2 解码器还原 U,逐段校验。"""
|
||||
d, total = lay["d"], lay["total"]
|
||||
dec_o = lzma.LZMADecompressor(
|
||||
format=lzma.FORMAT_RAW,
|
||||
filters=[{"id": lzma.FILTER_LZMA2, "dict_size": lay["dict_size_out"]}])
|
||||
I = dec_o.decompress(F[32:32 + lay["n"]])
|
||||
ok = True
|
||||
if len(I) != lay["Li"]:
|
||||
print("[verify] 外层输出 %d != I %d" % (len(I), lay["Li"]))
|
||||
return False
|
||||
dec_i = lzma.LZMADecompressor(
|
||||
format=lzma.FORMAT_RAW,
|
||||
filters=[{"id": lzma.FILTER_LZMA2, "dict_size": lay["dict_size_in"]}])
|
||||
U = dec_i.decompress(I)
|
||||
exp = d + total
|
||||
if len(U) != exp:
|
||||
print("[verify] 内层输出 %d != %d" % (len(U), exp))
|
||||
return False
|
||||
dc, ds = lay["dc"], lay["ds"]
|
||||
if U[:dc] != content:
|
||||
print("[verify] 内容不匹配")
|
||||
ok = False
|
||||
pos = 0
|
||||
for rel, data in files: # 内容文件排在 U 的最前面
|
||||
if U[pos:pos + len(data)] != data:
|
||||
print("[verify] 文件不匹配:", rel)
|
||||
ok = False
|
||||
pos += len(data)
|
||||
assert pos == dc
|
||||
if U[dc:dc + ds] != seed:
|
||||
print("[verify] seed 不匹配")
|
||||
ok = False
|
||||
if U[d:] != F:
|
||||
print("[verify] quine 自复制不匹配")
|
||||
ok = False
|
||||
print("[verify] 自解码: |I|=%d, |U|=%d, seed/文件/quine 全部匹配: %s"
|
||||
% (len(I), len(U), ok))
|
||||
return ok
|
||||
```
|
||||
仅仅是大佬的一句话,AI就能做出来,实在是太可怕了。最终我试了一下,官方的7-Zip可以正常解压,但是Windows资源管理器不行……Windows资源管理器使用的也是[libarchive](https://github.com/libarchive/libarchive),似乎是因为libarchive只支持2个解码器,并且[第二个解码器只能是像BCJ这样的过滤器](https://github.com/libarchive/libarchive/blob/8bb3bbdc7b117a1e22086a2260f2087aafa90687/libarchive/archive_read_support_format_7zip.c#L1475)……明明7-Zip官方是可以解压的,不知道如果把这个问题当作BUG报告给libarchive他们会不会解决?但目前存在兼容性问题的话我博客压缩包就先不用这个方案了吧🥲……
|
||||
## 对TXZ格式的尝试
|
||||
在做完7z格式的压缩包之后,我发现了一个问题,虽然7z确实很流行,但是在Linux下解压起来有点麻烦,7-Zip历史上主要面向Windows,Linux上长期以来更多依赖p7zip等第三方移植,因此生态集成度不如tar.xz,想要解压7z文件还得额外安装。
|
||||
不过Linux下也有个支持LZMA2算法的压缩软件,那就是前些年出过[后门](https://tukaani.org/xz-backdoor/)的XZ Utils,配合tar就可以做出TXZ(tar.xz)文件,甚至用我[博客终端](https://mabbs.github.io/linux/)中的BusyBox也能解压。我想了一下反正有AI,干脆一句话让AI帮我把blogquine.py改成tar.xz格式的好了,结果倒也没费多少功夫,AI就这样写出来了:
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ mkdir Mabbs
|
|||
curl -L -o Mabbs/README.md https://github.com/Mabbs/Mabbs/raw/main/README.md
|
||||
bundle exec jekyll build -d ../public_html
|
||||
python3 _tools/blogquine.py --src-dir blog --quine-dir blog ../public_html/ MayxBlog.7z
|
||||
7z a -mx9 -bd -bb0 MayxBlog-new.7z MayxBlog.7z
|
||||
mv MayxBlog-new.7z ../public_html/MayxBlog.7z
|
||||
mv MayxBlog.7z ../public_html/
|
||||
cd ../public_html/
|
||||
unset GIT_DIR
|
||||
git init
|
||||
|
|
@ -19,6 +18,6 @@ git branch pages
|
|||
git remote add codeberg ssh://git@codeberg.org/mayx/pages.git
|
||||
git remote add bitbucket ssh://git@bitbucket.org/unmayx/unmayx.bitbucket.io.git
|
||||
git push -f codeberg main
|
||||
git push -f codeberg pages
|
||||
git push -f codeberg pages
|
||||
git push -f bitbucket main
|
||||
/home/mayx/blog-env/node_modules/surge/bin/surge /home/mayx/public_html/ mayx.surge.sh
|
||||
|
|
@ -7,21 +7,18 @@ mkdir Mabbs
|
|||
curl -L -o Mabbs/README.md https://github.com/Mabbs/Mabbs/raw/main/README.md
|
||||
bundle exec jekyll build -d ../domains/mayx.serv00.net/public_html/
|
||||
python3 _tools/blogquine.py --src-dir blog --quine-dir blog ../domains/mayx.serv00.net/public_html/ MayxBlog.7z
|
||||
7zz a -mx9 -bd -bb0 MayxBlog-new.7z MayxBlog.7z
|
||||
mv MayxBlog-new.7z ../domains/mayx.serv00.net/public_html/MayxBlog.7z
|
||||
mv MayxBlog.7z ../domains/mayx.serv00.net/public_html/
|
||||
rsync -avz --delete ../domains/mayx.serv00.net/public_html/ mayx@web.sourceforge.net:/home/project-web/mayx/htdocs/
|
||||
cd ../domains/mayx.serv00.net/public_html/
|
||||
unset GIT_DIR
|
||||
git init
|
||||
git lfs install
|
||||
git lfs track "*.png"
|
||||
git lfs track "*.webp"
|
||||
git lfs track "*.moc"
|
||||
git lfs track "*.tgz"
|
||||
git lfs track "*.jpg"
|
||||
git lfs track "*.zip"
|
||||
git lfs track "*.7z"
|
||||
git lfs track "*.wasm"
|
||||
git branch -m main
|
||||
echo "---
|
||||
title: Mayx
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
/*
|
||||
* Mayx的博客 · /offline.html 控制页控制器(classic script,PJAX 兼容)
|
||||
*
|
||||
* 本体是 IIFE,不含模块语法:PJAX 换页时 jQuery 会重新执行本文件,
|
||||
* 每次执行都重新查询元素、重新绑定事件,天然幂等。真正的解包逻辑在
|
||||
* ES module /assets/js/offline-unpack.js 中,通过动态 import() 加载
|
||||
* (浏览器模块缓存保证 PJAX 反复进入本页也不会重复加载)。
|
||||
* 注意:本文件经 Liquid 原样输出,不能使用模板语法字符。
|
||||
*/
|
||||
|
||||
(function () {
|
||||
var MODULE_URL = '/assets/js/offline-unpack.js';
|
||||
// 压缩包大致量级(当前约 27MB),仅用于进度条比例,不影响正确性
|
||||
var EXPECTED_BYTES = 27 * 1024 * 1024;
|
||||
var busy = false;
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function fmtMB(bytes) { return (bytes / 1048576).toFixed(1) + ' MB'; }
|
||||
|
||||
function setMessage(text) {
|
||||
el('offline-message').textContent = text || '';
|
||||
}
|
||||
|
||||
function showProgress(show) {
|
||||
el('offline-progress-box').hidden = !show;
|
||||
}
|
||||
|
||||
function render(meta) {
|
||||
var online = !meta;
|
||||
el('offline-state').textContent = online
|
||||
? '在线模式(离线未启用)'
|
||||
: '离线模式(优先从本地缓存加载)';
|
||||
el('offline-version').textContent = online ? '-' : fmtMB(meta.payloadSize) + '(压缩包大小即版本号)';
|
||||
el('offline-files').textContent = online ? '-' : meta.files + ' 个文件 / ' + fmtMB(meta.bytes);
|
||||
el('offline-time').textContent = online ? '-' : new Date(meta.unpackedAt).toLocaleString();
|
||||
el('offline-persist').textContent = online ? '-' : (meta.persisted ? '已授权(缓存不会被自动回收)' : '未授权(浏览器空间紧张时可能回收缓存)');
|
||||
el('offline-enable').disabled = busy || !online;
|
||||
el('offline-disable').disabled = busy || online;
|
||||
}
|
||||
|
||||
function onProgress(info) {
|
||||
if (info.done) return;
|
||||
el('offline-progress').value = Math.min(100, (info.bytes / EXPECTED_BYTES) * 100);
|
||||
el('offline-progress-text').textContent =
|
||||
'已解包 ' + info.files + ' 个文件(' + fmtMB(info.bytes) + ')…';
|
||||
}
|
||||
|
||||
function withModule(fn) {
|
||||
return import(MODULE_URL).then(fn);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!('serviceWorker' in navigator) || !('caches' in window)) {
|
||||
el('offline-state').textContent = '当前浏览器不支持离线模式(需要 Service Worker)';
|
||||
el('offline-enable').disabled = true;
|
||||
el('offline-disable').disabled = true;
|
||||
return;
|
||||
}
|
||||
withModule(function (mod) { return mod.ensureFresh(); }).then(function (fresh) {
|
||||
render(fresh.ready ? fresh.meta : null);
|
||||
if (fresh.stale) {
|
||||
setMessage('检测到站点压缩包已更新,离线缓存已自动失效,当前处于在线模式。可重新启用离线模式。');
|
||||
}
|
||||
}).catch(function (e) {
|
||||
setMessage('状态检测失败:' + (e && e.message ? e.message : e));
|
||||
});
|
||||
}
|
||||
|
||||
function onEnable() {
|
||||
busy = true;
|
||||
render(null);
|
||||
showProgress(true);
|
||||
setMessage('正在下载并解包站点压缩包,请保持网络连接…');
|
||||
withModule(function (mod) { return mod.activateOffline({ onProgress: onProgress }); })
|
||||
.then(function (meta) {
|
||||
el('offline-progress').value = 100;
|
||||
el('offline-progress-text').textContent = '解包完成';
|
||||
setMessage('离线模式已启用。之后访问本博客将优先使用本地缓存,断网也能阅读。');
|
||||
busy = false;
|
||||
showProgress(false);
|
||||
render(meta); // 立即展示离线状态,不必等后台刷新
|
||||
})
|
||||
.catch(function (e) {
|
||||
setMessage('启用失败:' + (e && e.message ? e.message : e));
|
||||
busy = false;
|
||||
showProgress(false);
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function onDisable() {
|
||||
busy = true;
|
||||
setMessage('');
|
||||
withModule(function (mod) { return mod.deactivateOffline(); })
|
||||
.then(function () {
|
||||
setMessage('已切回在线模式,之后页面将通过网络加载。');
|
||||
busy = false;
|
||||
render(null);
|
||||
if (navigator.onLine) {
|
||||
setTimeout(function () { location.reload(); }, 800);
|
||||
}
|
||||
})
|
||||
.catch(function (e) {
|
||||
setMessage('操作失败:' + (e && e.message ? e.message : e));
|
||||
busy = false;
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function bind() {
|
||||
var enable = el('offline-enable');
|
||||
if (!enable) return; // 不在控制页(PJAX 容错)
|
||||
enable.addEventListener('click', onEnable);
|
||||
el('offline-disable').addEventListener('click', onDisable);
|
||||
refresh();
|
||||
}
|
||||
|
||||
bind();
|
||||
})();
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
/*
|
||||
* Mayx的博客 · 离线模式核心逻辑(/offline.html 控制页使用,ES module)
|
||||
*
|
||||
* - activateOffline():注册 SW,下载站点压缩包 /MayxBlog.7z,经 libarchive(WASM)
|
||||
* 流式解包,把每个文件按站点路径写入 Cache Storage,最后写入就绪标记
|
||||
* (清单 + payloadSize 版本号),自此进入离线模式。
|
||||
* - ensureFresh():HEAD 探测线上压缩包大小,与标记中的 payloadSize 不一致
|
||||
* 说明站点已更新 → 清空缓存,回退在线模式。
|
||||
* - deactivateOffline():清空缓存,手动切回在线模式。
|
||||
* - readStatus():读取当前离线状态,供控制页展示。
|
||||
*
|
||||
*/
|
||||
|
||||
const ARCHIVE_URL = '/MayxBlog.7z';
|
||||
const ARCHIVE_ROOT = 'blog/';
|
||||
const CACHE_NAME = 'mayxblog-offline-v1';
|
||||
const READY_KEY = '/__offline_manifest__';
|
||||
const WORKER_URL = '/vendor/libarchive/worker-bundle.js';
|
||||
|
||||
const MIME = {
|
||||
html: 'text/html; charset=utf-8',
|
||||
htm: 'text/html; charset=utf-8',
|
||||
css: 'text/css; charset=utf-8',
|
||||
js: 'application/javascript; charset=utf-8',
|
||||
mjs: 'application/javascript; charset=utf-8',
|
||||
json: 'application/json; charset=utf-8',
|
||||
xml: 'application/xml; charset=utf-8',
|
||||
xsl: 'text/xsl; charset=utf-8',
|
||||
opml: 'text/x-opml; charset=utf-8',
|
||||
txt: 'text/plain; charset=utf-8',
|
||||
md: 'text/markdown; charset=utf-8',
|
||||
csv: 'text/csv; charset=utf-8',
|
||||
svg: 'image/svg+xml',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
ico: 'image/x-icon',
|
||||
bmp: 'image/bmp',
|
||||
woff: 'font/woff',
|
||||
woff2: 'font/woff2',
|
||||
ttf: 'font/ttf',
|
||||
otf: 'font/otf',
|
||||
eot: 'application/vnd.ms-fontobject',
|
||||
mp3: 'audio/mpeg',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
zip: 'application/zip',
|
||||
wasm: 'application/wasm',
|
||||
mtn: 'application/octet-stream',
|
||||
moc: 'application/octet-stream',
|
||||
pdf: 'application/pdf',
|
||||
};
|
||||
|
||||
function mimeFor(pathname) {
|
||||
const dot = pathname.lastIndexOf('.');
|
||||
const ext = dot === -1 ? '' : pathname.slice(dot + 1).toLowerCase();
|
||||
return MIME[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function sitePathFor(entryPath) {
|
||||
const normalized = entryPath.replace(/\\/g, '/');
|
||||
if (!normalized.startsWith(ARCHIVE_ROOT)) return null;
|
||||
const relative = normalized.slice(ARCHIVE_ROOT.length);
|
||||
if (!relative) return null;
|
||||
|
||||
const segments = [];
|
||||
for (const segment of relative.split('/')) {
|
||||
if (!segment || segment === '.') continue;
|
||||
if (segment === '..') return null;
|
||||
segments.push(segment);
|
||||
}
|
||||
if (!segments.length) return null;
|
||||
return '/' + segments.join('/');
|
||||
}
|
||||
|
||||
/*
|
||||
* 在独立 worker 中打开一层压缩包并流式吐出条目;
|
||||
* 返回其中根级嵌套压缩包(下一层包装)的原始字节。
|
||||
*/
|
||||
function extractLayer(file, onEntry) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(WORKER_URL);
|
||||
const nested = [];
|
||||
let settled = false;
|
||||
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
worker.terminate();
|
||||
if (error) reject(error);
|
||||
else resolve(nested);
|
||||
};
|
||||
|
||||
worker.addEventListener('error', (event) => {
|
||||
finish(new Error(event.message || 'libarchive worker failed'));
|
||||
});
|
||||
|
||||
worker.addEventListener('message', ({ data }) => {
|
||||
if (data.type === 'ERROR') {
|
||||
finish(new Error(data.error && data.error.message ? data.error.message : 'libarchive failed'));
|
||||
return;
|
||||
}
|
||||
if (data.type === 'READY') {
|
||||
worker.postMessage({ type: 'OPEN', file });
|
||||
return;
|
||||
}
|
||||
if (data.type === 'OPENED') {
|
||||
worker.postMessage({ type: 'EXTRACT_FILES' });
|
||||
return;
|
||||
}
|
||||
if (data.type === 'END') {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
if (data.type !== 'ENTRY') return;
|
||||
|
||||
const entry = data.entry;
|
||||
if (entry.type !== 'FILE' || !entry.fileData) return;
|
||||
|
||||
// 根级压缩包是下一层包装,不是站点内容
|
||||
if (entry.path.replace(/\\/g, '/') === 'MayxBlog.7z') {
|
||||
nested.push(entry.fileData);
|
||||
return;
|
||||
}
|
||||
onEntry(entry);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* 几十 MB 的缓存是浏览器常见的回收对象,而重新解包代价高,尽量申请持久存储。 */
|
||||
async function requestPersistence() {
|
||||
if (!navigator.storage || !navigator.storage.persist) return false;
|
||||
try {
|
||||
if (await navigator.storage.persisted()) return true;
|
||||
return await navigator.storage.persist();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* 读取就绪标记(= 离线模式状态);损坏或缺清单的标记视为不存在。 */
|
||||
export async function readStatus() {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const hit = await cache.match(READY_KEY);
|
||||
if (!hit) return null;
|
||||
const meta = await hit.json().catch(() => null);
|
||||
if (!meta || !Array.isArray(meta.keys) || !meta.keys.length) return null;
|
||||
return meta;
|
||||
}
|
||||
|
||||
/*
|
||||
* 线上压缩包的大小即版本号。HEAD 不被 SW 拦截,拿到的一定是线上真实值;
|
||||
* 网络失败返回 null,调用方选择信任现有缓存而不是误伤。
|
||||
*/
|
||||
async function payloadSize() {
|
||||
try {
|
||||
const response = await fetch(ARCHIVE_URL, { method: 'HEAD', cache: 'no-store' });
|
||||
if (!response.ok) return null;
|
||||
const size = Number(response.headers.get('content-length'));
|
||||
return Number.isFinite(size) && size > 0 ? size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 控制页打开时调用:未启用 → {ready:false};已启用且版本一致 → {ready:true, meta};
|
||||
* 版本不一致 → 清空缓存并返回 {ready:false, stale:true}(已回退在线模式)。
|
||||
*/
|
||||
export async function ensureFresh() {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const size = await payloadSize();
|
||||
const meta = await readStatus();
|
||||
|
||||
if (!meta) return { ready: false, size, stale: false };
|
||||
if (size && meta.payloadSize !== size) {
|
||||
await caches.delete(CACHE_NAME);
|
||||
return { ready: false, size, stale: true };
|
||||
}
|
||||
return { ready: true, size, stale: false, meta };
|
||||
}
|
||||
|
||||
/*
|
||||
* 启用离线模式:注册 SW → 清空旧缓存(同时清掉上次中断的残留,保证随后
|
||||
* 的压缩包下载不会被旧缓存喂旧的 quine 副本)→ 下载并逐层解包 → 写标记。
|
||||
*/
|
||||
export async function activateOffline({ onProgress } = {}) {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
throw new Error('当前浏览器不支持 Service Worker,无法启用离线模式。');
|
||||
}
|
||||
await navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
await navigator.serviceWorker.ready;
|
||||
await caches.delete(CACHE_NAME);
|
||||
|
||||
const persisted = await requestPersistence();
|
||||
|
||||
// 写操作串行链,保证按到达顺序落盘且不产生无界并发
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
let writes = Promise.resolve();
|
||||
const keys = new Set();
|
||||
let files = 0;
|
||||
let bytes = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const response = await fetch(ARCHIVE_URL, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`无法下载站点压缩包:HTTP ${response.status}`);
|
||||
const outer = await response.arrayBuffer();
|
||||
const downloadedBytes = outer.byteLength;
|
||||
|
||||
let layer = [outer];
|
||||
while (layer.length) {
|
||||
const bytesThisLayer = layer.shift();
|
||||
const file = new File([bytesThisLayer], 'MayxBlog.7z');
|
||||
|
||||
layer = await extractLayer(file, (entry) => {
|
||||
const key = sitePathFor(entry.path);
|
||||
if (!key) {
|
||||
skipped += 1;
|
||||
return;
|
||||
}
|
||||
const body = entry.fileData;
|
||||
files += 1;
|
||||
bytes += body.byteLength;
|
||||
|
||||
writes = writes.then(async () => {
|
||||
await cache.put(key, new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': mimeFor(key) },
|
||||
}));
|
||||
keys.add(key);
|
||||
});
|
||||
|
||||
if (onProgress) onProgress({ done: false, files, bytes, skipped });
|
||||
});
|
||||
}
|
||||
|
||||
await writes;
|
||||
|
||||
const meta = {
|
||||
files,
|
||||
bytes,
|
||||
skipped,
|
||||
persisted,
|
||||
payloadSize: downloadedBytes,
|
||||
failed: files - keys.size,
|
||||
keys: [...keys],
|
||||
unpackedAt: new Date().toISOString(),
|
||||
};
|
||||
await cache.put(
|
||||
READY_KEY,
|
||||
new Response(JSON.stringify(meta), {
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
})
|
||||
);
|
||||
|
||||
if (onProgress) onProgress({ done: true, cached: false, ...meta });
|
||||
return meta;
|
||||
}
|
||||
|
||||
/* 切回在线模式:删掉缓存(含标记),SW 随即恢复纯转发。返回之前是否处于离线模式。 */
|
||||
export async function deactivateOffline() {
|
||||
const had = await readStatus();
|
||||
await caches.delete(CACHE_NAME);
|
||||
return Boolean(had);
|
||||
}
|
||||
Binary file not shown.
BIN
images/GEDSC AIRWAY BILL TO CHINA.jpg
Normal file
BIN
images/GEDSC AIRWAY BILL TO CHINA.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 331 KiB |
BIN
images/MOS_Demo.png
Normal file
BIN
images/MOS_Demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 748 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 209 KiB |
37
offline.md
37
offline.md
|
|
@ -1,37 +0,0 @@
|
|||
---
|
||||
layout: default
|
||||
title: 离线模式
|
||||
---
|
||||
|
||||
# 离线模式
|
||||
|
||||
本页用于管理本博客的离线阅读功能:
|
||||
|
||||
- **启用离线模式**:浏览器会下载整站压缩包(`MayxBlog.7z`)并解包到本地缓存,之后即使没有网络也能正常浏览博客;
|
||||
- **自动回退**:当站点压缩包更新(博客重新部署)时,离线缓存会自动失效并回退到在线模式,下次重新启用即可获取最新内容;
|
||||
- **手动切换**:离线模式下可以随时在本页切回在线模式,切回后页面将重新通过网络加载。
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>当前状态</th><td id="offline-state">检测中…</td></tr>
|
||||
<tr><th>缓存版本</th><td id="offline-version">-</td></tr>
|
||||
<tr><th>文件统计</th><td id="offline-files">-</td></tr>
|
||||
<tr><th>解包时间</th><td id="offline-time">-</td></tr>
|
||||
<tr><th>持久存储</th><td id="offline-persist">-</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p id="offline-progress-box" hidden="hidden">
|
||||
<progress id="offline-progress" max="100" value="0"></progress>
|
||||
|
||||
<small id="offline-progress-text">准备中…</small>
|
||||
</p>
|
||||
|
||||
<p><strong id="offline-message"></strong></p>
|
||||
|
||||
<p>
|
||||
<button type="button" id="offline-enable">启用离线模式</button>
|
||||
<button type="button" id="offline-disable">切换回在线模式</button>
|
||||
</p>
|
||||
|
||||
<script src="/assets/js/offline-page.js"></script>
|
||||
140
sw.js
140
sw.js
|
|
@ -1,140 +0,0 @@
|
|||
/*
|
||||
* Mayx的博客 · 离线模式 Service Worker
|
||||
*
|
||||
* 两种模式(由 Cache Storage 中是否存在就绪标记决定):
|
||||
* - 在线模式(默认,无标记):所有请求原样转发网络,SW 形同虚设。
|
||||
* - 离线模式(/offline.html 启用后写入标记):同源 GET 请求缓存优先,
|
||||
* 缓存未命中且清单里没有的路径才回源网络,实现断网阅读。
|
||||
*
|
||||
* 版本控制:压缩包 /MayxBlog.7z 的 content-length 即版本号(每次重新部署
|
||||
* 都会变化)。SW 激活时、以及导航请求按 RECHECK_INTERVAL 节流复核;
|
||||
* 一旦发现线上压缩包与已缓存版本不一致,立即清空缓存 → 自动回退在线模式。
|
||||
* 网络不可用时复核失败,保留缓存(离线阅读本来就是离线模式的目的)。
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'mayxblog-offline-v1';
|
||||
const READY_KEY = '/__offline_manifest__';
|
||||
const ARCHIVE_URL = '/MayxBlog.7z';
|
||||
const RECHECK_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
let lastRecheck = 0;
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||
)
|
||||
.then(verifyPayloadVersion)
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
* HEAD 请求不被下面的 fetch 处理器拦截,这里读到的一定是线上真实压缩包,
|
||||
* 而不是缓存里的 quine 副本。网络失败返回 null,调用方选择信任现有缓存。
|
||||
*/
|
||||
async function payloadSize() {
|
||||
try {
|
||||
const response = await fetch(ARCHIVE_URL, { method: 'HEAD', cache: 'no-store' });
|
||||
if (!response.ok) return null;
|
||||
const size = Number(response.headers.get('content-length'));
|
||||
return Number.isFinite(size) && size > 0 ? size : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 就绪标记同时是清单:记录了解包写入的所有路径。没有它就无法区分
|
||||
* “站点本来就没有这个页面”和“缓存条目被浏览器回收了”。
|
||||
*/
|
||||
async function readManifest(cache) {
|
||||
const hit = await cache.match(READY_KEY);
|
||||
if (!hit) return null;
|
||||
const meta = await hit.json().catch(() => null);
|
||||
if (!meta || !Array.isArray(meta.keys) || !meta.keys.length) return null;
|
||||
return meta;
|
||||
}
|
||||
|
||||
/* 压缩包更新 → 清空缓存(即回退在线模式);返回是否发生了失效。 */
|
||||
async function verifyPayloadVersion() {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const meta = await readManifest(cache);
|
||||
if (!meta) return false;
|
||||
const size = await payloadSize();
|
||||
if (size && meta.payloadSize !== size) {
|
||||
await caches.delete(CACHE_NAME);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* 服务器对目录索引的处理与 Jekyll 产物一致,这里补齐候选路径。 */
|
||||
function candidatesFor(pathname) {
|
||||
const keys = [pathname];
|
||||
if (pathname.endsWith('/')) {
|
||||
keys.push(pathname + 'index.html');
|
||||
} else {
|
||||
keys.push(pathname + '/index.html');
|
||||
keys.push(pathname + '.html');
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const request = event.request;
|
||||
if (request.method !== 'GET') return; // HEAD 等直通网络(版本探测依赖这一点)
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return; // 跨域请求不干预
|
||||
|
||||
// 显式 no-store 的请求(重新下载压缩包等)绕过缓存,避免吃到旧的 quine 副本
|
||||
if (request.cache === 'no-store') return;
|
||||
|
||||
event.respondWith(respond(request, url, event));
|
||||
});
|
||||
|
||||
async function respond(request, url, event) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
|
||||
// 导航请求顺带做节流版本复核;失效发生在后台,本次响应不受影响,
|
||||
// 下一次请求自然落入在线模式。
|
||||
if (request.mode === 'navigate' && Date.now() - lastRecheck > RECHECK_INTERVAL) {
|
||||
lastRecheck = Date.now();
|
||||
event.waitUntil(verifyPayloadVersion());
|
||||
}
|
||||
|
||||
const candidates = candidatesFor(url.pathname);
|
||||
for (const key of candidates) {
|
||||
const hit = await cache.match(key);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
const meta = await readManifest(cache);
|
||||
if (!meta) return fetch(request); // 在线模式:纯转发
|
||||
|
||||
// 清单承诺过这个路径但缓存里没有 → 条目被浏览器回收,缓存已不可信,
|
||||
// 整体作废并回退在线模式。
|
||||
if (candidates.some((key) => meta.keys.includes(key))) {
|
||||
await caches.delete(CACHE_NAME);
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
// 清单之外的路径回源网络;断网时的导航请求用站点 404 页兜底。
|
||||
try {
|
||||
return await fetch(request);
|
||||
} catch (error) {
|
||||
if (request.mode === 'navigate') {
|
||||
const notFound = await cache.match('/404.html');
|
||||
if (notFound) {
|
||||
return new Response(notFound.body, { status: 404, headers: notFound.headers });
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
21
vendor/libarchive/LICENSE
vendored
21
vendor/libarchive/LICENSE
vendored
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2018 ნიკა
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
15
vendor/libarchive/wasm-gen/libarchive.js
vendored
15
vendor/libarchive/wasm-gen/libarchive.js
vendored
File diff suppressed because one or more lines are too long
BIN
vendor/libarchive/wasm-gen/libarchive.wasm
vendored
BIN
vendor/libarchive/wasm-gen/libarchive.wasm
vendored
Binary file not shown.
1
vendor/libarchive/worker-bundle.js
vendored
1
vendor/libarchive/worker-bundle.js
vendored
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user