first commit

This commit is contained in:
2026-10-26 17:44:44 +08:00
commit e4eee1b3b7
49 changed files with 21575 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import AdmZip from 'adm-zip'
const root = process.cwd()
const devRoot = process.env.ANDROID_DEV_ROOT || 'E:\\AiCodeGitea\\ven\\AndroidDev'
const jdkHome = fs.existsSync(path.join(process.env.JAVA_HOME || '', 'bin', 'java.exe'))
? process.env.JAVA_HOME
: path.join(devRoot, 'jdk17')
const sdkHome = fs.existsSync(path.join(process.env.ANDROID_HOME || '', 'platforms'))
? process.env.ANDROID_HOME
: path.join(devRoot, 'Sdk')
const gradleHome = path.join(devRoot, 'gradle-8.7')
const buildTools = path.join(sdkHome, 'build-tools', '34.0.0')
const androidJar = path.join(sdkHome, 'platforms', 'android-34', 'android.jar')
const java = path.join(jdkHome, 'bin', 'java.exe')
const jar = path.join(jdkHome, 'bin', 'jar.exe')
const keytool = path.join(jdkHome, 'bin', 'keytool.exe')
const kotlincJars = path.join(gradleHome, 'lib', '*')
const kotlinStdlib = fs
.readdirSync(path.join(gradleHome, 'lib'))
.filter((name) => /^kotlin-stdlib-[\d.]+\.jar$/.test(name))
.sort()
.pop()
if (!kotlinStdlib) throw new Error('kotlin-stdlib not found in Gradle lib')
const env = {
...process.env,
JAVA_HOME: jdkHome,
ANDROID_HOME: sdkHome,
ANDROID_SDK_ROOT: sdkHome,
PATH: [
path.join(jdkHome, 'bin'),
buildTools,
path.join(sdkHome, 'platform-tools'),
process.env.PATH || ''
].join(';')
}
const appDir = path.join(root, 'android', 'app', 'src', 'main')
const manifest = path.join(appDir, 'AndroidManifest.xml')
const resDir = path.join(appDir, 'res')
const assetsDir = path.join(appDir, 'assets')
const wwwDir = path.join(assetsDir, 'www')
const sourceDir = path.join(appDir, 'java')
const outDir = path.join(root, 'android', 'build')
const classesDir = path.join(outDir, 'classes')
const dexDir = path.join(outDir, 'dex')
const apkName = 'PhoneViewer-0.4.1.apk'
function run(command, args, options = {}) {
console.log('>', path.basename(command), args.join(' '))
execFileSync(command, args, { stdio: 'inherit', env, ...options })
}
function runBat(batFile, args, options = {}) {
run('cmd.exe', ['/c', batFile, ...args], options)
}
fs.rmSync(outDir, { recursive: true, force: true })
fs.mkdirSync(outDir, { recursive: true })
fs.mkdirSync(classesDir, { recursive: true })
fs.mkdirSync(dexDir, { recursive: true })
console.log('== building web assets ==')
run('cmd.exe', ['/c', 'npm run build'], { cwd: root })
console.log('== copying web assets into Android assets ==')
fs.rmSync(wwwDir, { recursive: true, force: true })
fs.mkdirSync(wwwDir, { recursive: true })
fs.cpSync(path.join(root, 'dist'), wwwDir, { recursive: true })
for (const name of fs.readdirSync(wwwDir)) {
if (name === 'sw.js' || name.startsWith('workbox-')) {
fs.rmSync(path.join(wwwDir, name), { force: true })
}
}
console.log('== compiling Kotlin ==')
const kotlinFiles = []
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full)
else if (entry.name.endsWith('.kt')) kotlinFiles.push(full)
}
}
walk(sourceDir)
run(java, [
'-cp',
kotlincJars,
'org.jetbrains.kotlin.cli.jvm.K2JVMCompiler',
'-no-stdlib',
'-no-reflect',
'-classpath',
`${androidJar};${path.join(gradleHome, 'lib', kotlinStdlib)}`,
'-jvm-target',
'17',
'-d',
classesDir,
...kotlinFiles
])
console.log('== creating classes.jar ==')
const classesJar = path.join(outDir, 'classes.jar')
run(jar, ['cf', classesJar, '-C', classesDir, '.'])
console.log('== dexing with d8 ==')
runBat(path.join(buildTools, 'd8.bat'), [
'--release',
'--lib',
androidJar,
'--min-api',
'26',
'--output',
dexDir,
classesJar,
path.join(gradleHome, 'lib', kotlinStdlib)
])
console.log('== compiling resources ==')
const resZip = path.join(outDir, 'resources.zip')
run(path.join(buildTools, 'aapt2.exe'), ['compile', '-o', resZip, '--dir', resDir])
console.log('== linking base apk ==')
const baseApk = path.join(outDir, 'base.apk')
run(path.join(buildTools, 'aapt2.exe'), [
'link',
'-o',
baseApk,
'-I',
androidJar,
'--manifest',
manifest,
'-R',
resZip,
'--min-sdk-version',
'26',
'--target-sdk-version',
'34',
'--version-code',
'5',
'--version-name',
'0.4.1',
'--auto-add-overlay'
])
console.log('== adding assets and classes.dex ==')
const unsigned = path.join(outDir, 'unsigned.apk')
fs.rmSync(unsigned, { force: true })
const zip = new AdmZip(baseApk)
function addDirectoryToZip(directory, prefix) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const full = path.join(directory, entry.name)
const name = `${prefix}/${entry.name}`
if (entry.isDirectory()) addDirectoryToZip(full, name)
else zip.addFile(name, fs.readFileSync(full))
}
}
addDirectoryToZip(wwwDir, 'assets/www')
for (const dex of fs.readdirSync(dexDir).filter((name) => name.endsWith('.dex'))) {
zip.addFile(dex, fs.readFileSync(path.join(dexDir, dex)))
}
zip.writeZip(unsigned)
console.log('== zipalign ==')
const aligned = path.join(outDir, 'aligned.apk')
run(path.join(buildTools, 'zipalign.exe'), ['-p', '-f', '4', unsigned, aligned])
console.log('== signing ==')
const keystoreDir = path.join(root, 'android', 'keystore')
const keystore = path.join(keystoreDir, 'phoneviewer.jks')
const storePass = process.env.PHONEVIEWER_KEYSTORE_PASS
const keyPass = process.env.PHONEVIEWER_KEY_PASS ?? storePass
if (!storePass) {
throw new Error('Set PHONEVIEWER_KEYSTORE_PASS before building the signed APK.')
}
const alias = 'phoneviewer'
if (!fs.existsSync(keystore)) {
fs.mkdirSync(keystoreDir, { recursive: true })
run(keytool, [
'-genkeypair',
'-v',
'-keystore',
keystore,
'-storetype',
'JKS',
'-alias',
alias,
'-keyalg',
'RSA',
'-keysize',
'2048',
'-validity',
'10000',
'-storepass',
storePass,
'-keypass',
keyPass,
'-dname',
'CN=PhoneViewer, OU=Android, O=PhoneViewer, L=Beijing, ST=Beijing, C=CN',
'-noprompt'
])
}
const finalApk = path.join(outDir, apkName)
runBat(path.join(buildTools, 'apksigner.bat'), [
'sign',
'--ks',
keystore,
'--ks-key-alias',
alias,
'--ks-pass',
`pass:${storePass}`,
'--key-pass',
`pass:${keyPass}`,
'--out',
finalApk,
aligned
])
runBat(path.join(buildTools, 'apksigner.bat'), ['verify', '--verbose', '--print-certs', finalApk])
const rootApk = path.join(root, apkName)
fs.copyFileSync(finalApk, rootApk)
console.log('')
console.log('APK ready:', rootApk)
console.log('APK size:', (fs.statSync(rootApk).size / 1024 / 1024).toFixed(2), 'MB')
+174
View File
@@ -0,0 +1,174 @@
import fs from 'node:fs'
import path from 'node:path'
import zlib from 'node:zlib'
const OUT = path.resolve('public/icons')
fs.mkdirSync(OUT, { recursive: true })
const CRC_TABLE = (() => {
const table = new Uint32Array(256)
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
table[n] = c >>> 0
}
return table
})()
function crc32(buf) {
let c = 0xffffffff
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8)
return (c ^ 0xffffffff) >>> 0
}
function chunk(type, data) {
const len = Buffer.alloc(4)
len.writeUInt32BE(data.length, 0)
const t = Buffer.from(type, 'ascii')
const crc = Buffer.alloc(4)
crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0)
return Buffer.concat([len, t, data, crc])
}
function encodePng(width, height, rgba) {
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(width, 0)
ihdr.writeUInt32BE(height, 4)
ihdr[8] = 8
ihdr[9] = 6
ihdr[10] = 0
ihdr[11] = 0
ihdr[12] = 0
const raw = Buffer.alloc((width * 4 + 1) * height)
for (let y = 0; y < height; y++) {
raw[y * (width * 4 + 1)] = 0
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4)
}
const idat = zlib.deflateSync(raw, { level: 9 })
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', idat),
chunk('IEND', Buffer.alloc(0))
])
}
function clamp01(v) {
return v < 0 ? 0 : v > 1 ? 1 : v
}
function mix(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
}
function roundedRectInside(x, y, cx, cy, hw, hh, r) {
const dx = Math.abs(x - cx) - (hw - r)
const dy = Math.abs(y - cy) - (hh - r)
const ox = Math.max(dx, 0)
const oy = Math.max(dy, 0)
return Math.hypot(ox, oy) + Math.min(Math.max(dx, dy), 0) - r <= 0
}
function segmentDistance(px, py, ax, ay, bx, by) {
const dx = bx - ax
const dy = by - ay
const len2 = dx * dx + dy * dy || 1
let t = ((px - ax) * dx + (py - ay) * dy) / len2
t = t < 0 ? 0 : t > 1 ? 1 : t
return Math.hypot(px - (ax + dx * t), py - (ay + dy * t))
}
function ringInside(px, py, cx, cy, r, width) {
return Math.abs(Math.hypot(px - cx, py - cy) - r) <= width / 2
}
function arcInside(px, py, cx, cy, r, width, a0, a1) {
if (!ringInside(px, py, cx, cy, r, width)) return false
let a = Math.atan2(py - cy, px - cx)
while (a < a0) a += Math.PI * 2
return a <= a1
}
function samplePlan(x, y, pad) {
const sx = (x - pad) / (1 - pad * 2)
const sy = (y - pad) / (1 - pad * 2)
const line = 0.048
const l = 0.022
// outer wall
if (segmentDistance(sx, sy, 0.2, 0.3, 0.8, 0.3) < line / 2) return true
if (segmentDistance(sx, sy, 0.8, 0.3, 0.8, 0.7) < line / 2) return true
if (segmentDistance(sx, sy, 0.8, 0.7, 0.2, 0.7) < line / 2) return true
if (segmentDistance(sx, sy, 0.2, 0.7, 0.2, 0.3) < line / 2) return true
// inner walls
if (segmentDistance(sx, sy, 0.52, 0.3, 0.52, 0.7) < l) return true
if (segmentDistance(sx, sy, 0.52, 0.5, 0.8, 0.5) < l) return true
// door swing
if (arcInside(sx, sy, 0.52, 0.5, 0.15, l * 1.2, -Math.PI / 2, 0)) return true
if (segmentDistance(sx, sy, 0.52, 0.5, 0.52, 0.65) < l * 0.9) return true
// round table
if (ringInside(sx, sy, 0.34, 0.42, 0.075, l * 1.3)) return true
if (ringInside(sx, sy, 0.34, 0.42, 0.028, l)) return true
// bed
if (segmentDistance(sx, sy, 0.58, 0.57, 0.74, 0.57) < l * 0.9) return true
if (segmentDistance(sx, sy, 0.74, 0.57, 0.74, 0.66) < l * 0.9) return true
if (segmentDistance(sx, sy, 0.74, 0.66, 0.58, 0.66) < l * 0.9) return true
if (segmentDistance(sx, sy, 0.58, 0.66, 0.58, 0.57) < l * 0.9) return true
return false
}
function render(size, { maskable = false, fullSquare = false } = {}) {
const aa = 4
const w = size * aa
const rgba = Buffer.alloc(size * size * 4)
const pad = maskable ? 0.2 : 0.14
const bgTop = [96, 156, 255]
const bgBottom = [29, 78, 216]
const bgDeep = [12, 22, 44]
for (let py = 0; py < size; py++) {
for (let px = 0; px < size; px++) {
let sr = 0
let sg = 0
let sb = 0
let sa = 0
for (let oy = 0; oy < aa; oy++) {
for (let ox = 0; ox < aa; ox++) {
const x = (px + (ox + 0.5) / aa) / size
const y = (py + (oy + 0.5) / aa) / size
const insideBg = fullSquare || roundedRectInside(x, y, 0.5, 0.5, 0.5, 0.5, 0.235)
if (!insideBg) continue
const t = clamp01((x + y) / 2.2)
let color = mix(bgTop, bgBottom, t)
const vignette = clamp01(Math.hypot(x - 0.3, y - 0.26) * 1.35)
color = mix(color, bgDeep, vignette * 0.24)
if (samplePlan(x, y, pad)) {
color = mix([255, 255, 255], [192, 219, 255], clamp01((x + y) / 2.4) * 0.45)
}
sr += color[0]
sg += color[1]
sb += color[2]
sa += 1
}
}
const total = aa * aa
const i = (py * size + px) * 4
if (sa === 0) {
rgba[i] = 0
rgba[i + 1] = 0
rgba[i + 2] = 0
rgba[i + 3] = 0
} else {
rgba[i] = Math.round(sr / sa)
rgba[i + 1] = Math.round(sg / sa)
rgba[i + 2] = Math.round(sb / sa)
rgba[i + 3] = Math.round((sa / total) * 255)
}
}
}
return encodePng(size, size, rgba)
}
fs.writeFileSync(path.join(OUT, 'icon-192.png'), render(192))
fs.writeFileSync(path.join(OUT, 'icon-512.png'), render(512))
fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), render(180, { fullSquare: true }))
fs.writeFileSync(path.join(OUT, 'icon-maskable-512.png'), render(512, { maskable: true, fullSquare: true }))
console.log('icons written to', OUT)
+86
View File
@@ -0,0 +1,86 @@
import fs from 'node:fs'
import path from 'node:path'
const source = process.argv[2] ?? 'node_modules/@mlightcad/libredwg-web/wasm/libredwg-web.wasm'
const target = process.argv[3] ?? 'public/vendor/libredwg/wasm/libredwg-web.wasm'
const INITIAL_PAGES = 4096 // 256 MiB; the module can still grow up to its 4 GiB maximum.
const MAX_PAGES = 65536
const buf = fs.readFileSync(source)
if (buf.readUInt32LE(0) !== 0x6d736100 || buf.readUInt32LE(4) !== 1) {
throw new Error('Not a WebAssembly module')
}
function readU32(data, offset) {
let value = 0
let shift = 0
let pos = offset
while (true) {
const byte = data[pos++]
value += (byte & 0x7f) * 2 ** shift
if (!(byte & 0x80)) break
shift += 7
if (shift > 35) throw new Error('Invalid LEB128 value')
}
return [value >>> 0, pos]
}
function encodeU32(value) {
const bytes = []
let v = value >>> 0
do {
let byte = v & 0x7f
v >>>= 7
if (v) byte |= 0x80
bytes.push(byte)
} while (v)
return Buffer.from(bytes)
}
function encodeSection(id, payload) {
return Buffer.concat([Buffer.from([id]), encodeU32(payload.length), payload])
}
const sections = []
let pos = 8
while (pos < buf.length) {
const id = buf[pos++]
const [size, afterSize] = readU32(buf, pos)
const payload = buf.subarray(afterSize, afterSize + size)
sections.push({ id, payload })
pos = afterSize + size
}
let patched = false
const output = []
output.push(buf.subarray(0, 8))
for (const section of sections) {
if (section.id === 5 && !patched) {
const [count, afterCount] = readU32(section.payload, 0)
if (count < 1) throw new Error('Memory section has no memory')
const flags = section.payload[afterCount]
if ((flags & 0x01) === 0) throw new Error('Memory has no maximum; refusing to patch')
const [oldMin, afterMin] = readU32(section.payload, afterCount + 1)
const [oldMax] = readU32(section.payload, afterMin)
if (oldMin !== 16384) throw new Error(`Unexpected initial memory: ${oldMin}`)
const payload = Buffer.concat([
encodeU32(count),
Buffer.from([flags]),
encodeU32(INITIAL_PAGES),
encodeU32(MAX_PAGES),
section.payload.subarray(afterMin + encodeU32(oldMax).length)
])
output.push(encodeSection(5, payload))
patched = true
console.log(
`patched memory: ${oldMin} -> ${INITIAL_PAGES} pages (${Math.round((INITIAL_PAGES * 64) / 1024)} MiB)`
)
} else {
output.push(encodeSection(section.id, section.payload))
}
}
if (!patched) throw new Error('Memory section not found')
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(target, Buffer.concat(output))
console.log('wrote', target, fs.statSync(target).size, 'bytes')