1
0
mirror of https://gitcode.com/github-mirrors/react-native-update-cli.git synced 2025-09-17 18:06:10 +08:00
Code Issues Packages Projects Releases Wiki Activity GitHub Gitee

Compare commits

..

1 Commits

Author SHA1 Message Date
sunnylqm
44834ee477 v1.20.2 2024-03-06 10:19:54 +08:00
44 changed files with 4637 additions and 5659 deletions

View File

@@ -1,47 +0,0 @@
name: lint
on:
pull_request:
branches:
- master
push:
branches:
- master
# Cancel a currently running workflow from the same PR/branch/tag
# when a new workflow is triggered
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
- name: Install Dependency
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: '--max_old_space_size=4096'
run: bun install --frozen-lockfile
- name: Run lint
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: '--max_old_space_size=4096'
run: bun lint

View File

@@ -1,22 +0,0 @@
name: Publish Package to npmjs
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- run: bun install --frozen-lockfile
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

1
.npmrc
View File

@@ -1 +0,0 @@
registry = "https://registry.npmjs.org"

15
.swcrc
View File

@@ -1,15 +0,0 @@
{
"jsc": {
"loose": true,
"target": "es2018",
"parser": {
"syntax": "typescript",
"tsx": false,
"decorators": false,
"dynamicImport": false
}
},
"module": {
"type": "commonjs"
}
}

View File

@@ -1,39 +0,0 @@
{
"files": {
"ignore": ["lib"]
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "off",
"noAssignInExpressions": "off",
"noDoubleEquals": "off"
},
"style": {
"noNonNullAssertion": "off"
},
"a11y": {
"useValidAnchor": "off"
},
"performance": {
"noDelete": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80,
"attributePosition": "auto"
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
}
}

1150
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,11 @@
"defaultCommand": "help",
"commands": {
"help": {},
"login": {},
"logout": {},
"me": {},
"createApp": {
"options": {
"name": {
@@ -31,10 +33,9 @@
}
}
},
"uploadIpa": {},
"uploadApk": {},
"uploadApp": {},
"parseApp": {},
"parseIpa": {},
"parseApk": {},
"packages": {
@@ -44,6 +45,7 @@
}
}
},
"publish": {
"options": {
"platform": {
@@ -67,6 +69,7 @@
}
}
},
"update": {
"options": {
"platform": {
@@ -78,20 +81,13 @@
"packageId": {
"hasValue": true
},
"packageVersion": {
"hasValue": true
},
"minPackageVersion": {
"hasValue": true
},
"maxPackageVersion": {
"hasValue": true
},
"rollout": {
"hasValue": true
}
}
},
"updateVersionInfo": {
"options": {
"platform": {
@@ -114,6 +110,7 @@
}
}
},
"build": {
"description": "Bundle javascript and copy assets."
},
@@ -136,27 +133,15 @@
"hasValue": true
},
"intermediaDir": {
"default": "${tempDir}/intermedia/${platform}",
"default": ".pushy/intermedia/${platform}",
"hasValue": true
},
"output": {
"default": "${tempDir}/output/${platform}.${time}.ppk",
"default": ".pushy/output/${platform}.${time}.ppk",
"hasValue": true
},
"sourcemap": {
"default": false
},
"taro": {
"default": false
},
"expo": {
"default": false
},
"rncli": {
"default": false
},
"disableHermes": {
"default": false
}
}
},
@@ -167,7 +152,7 @@
"description": "Create diff patch",
"options": {
"output": {
"default": "${tempDir}/output/diff",
"default": ".pushy/output/diff",
"hasValue": true
}
}
@@ -176,7 +161,7 @@
"description": "Create diff patch from a Android package(.apk)",
"options": {
"output": {
"default": "${tempDir}/output/diff-${time}.apk-patch",
"default": ".pushy/output/diff-${time}.apk-patch",
"hasValue": true
}
}
@@ -185,7 +170,7 @@
"description": "Create diff patch from a iOS package(.ipa)",
"options": {
"output": {
"default": "${tempDir}/output/diff-${time}.ipa-patch",
"default": ".pushy/output/diff-${time}.ipa-patch",
"hasValue": true
}
}
@@ -194,7 +179,7 @@
"description": "Create hdiff patch",
"options": {
"output": {
"default": "${tempDir}/output/hdiff",
"default": ".pushy/output/hdiff",
"hasValue": true
}
}
@@ -203,25 +188,7 @@
"description": "Create hdiff patch from a Android package(.apk)",
"options": {
"output": {
"default": "${tempDir}/output/hdiff-${time}.apk-patch",
"hasValue": true
}
}
},
"hdiffFromPPK": {
"description": "Create hdiff patch from a Prepare package(.ppk)",
"options": {
"output": {
"default": "${tempDir}/output/hdiff-${time}.ppk-patch",
"hasValue": true
}
}
},
"hdiffFromApp": {
"description": "Create hdiff patch from a Harmony package(.app)",
"options": {
"output": {
"default": "${tempDir}/output/hdiff-${time}.app-patch",
"default": ".pushy/output/hdiff-${time}.apk-patch",
"hasValue": true
}
}
@@ -230,7 +197,7 @@
"description": "Create hdiff patch from a iOS package(.ipa)",
"options": {
"output": {
"default": "${tempDir}/output/hdiff-${time}.ipa-patch",
"default": ".pushy/output/hdiff-${time}.ipa-patch",
"hasValue": true
}
}

View File

@@ -1 +1,4 @@
/**
* Created by tdzl2003 on 2/22/16.
*/
module.exports = require('./lib');

View File

@@ -1,11 +1,10 @@
{
"name": "react-native-update-cli",
"version": "1.43.5",
"description": "command line tool for react-native-update (remote updates for react native)",
"version": "1.20.2",
"description": "Command tools for javaScript updater with `pushy` service for react native apps.",
"main": "index.js",
"bin": {
"pushy": "lib/index.js",
"cresc": "lib/index.js"
"pushy": "lib/index.js"
},
"files": [
"lib",
@@ -13,9 +12,7 @@
"cli.json"
],
"scripts": {
"build": "swc src -d lib --strip-leading-paths",
"prepare": "npm run build && chmod +x lib/index.js",
"lint": "tsc --noEmit & biome check --write ."
"prepublish": "babel src --out-dir lib"
},
"repository": {
"type": "git",
@@ -25,7 +22,6 @@
"react-native",
"ios",
"android",
"harmony",
"update"
],
"author": "reactnativecn",
@@ -35,50 +31,30 @@
},
"homepage": "https://github.com/reactnativecn/react-native-pushy/tree/master/react-native-pushy-cli",
"dependencies": {
"@badisi/latest-version": "^7.0.13",
"bplist-parser": "^0.3.2",
"bytebuffer": "^5.0.1",
"cgbi-to-png": "^1.0.7",
"chalk": "4",
"app-info-parser": "github:sunnylqm/app-info-parser#fix/support-ascii-versionname",
"cli-arguments": "^0.2.1",
"commander": "^13",
"compare-versions": "^6.1.1",
"filesize-parser": "^1.5.1",
"form-data": "^4.0.2",
"filesize-parser": "^1.5.0",
"fs-extra": "8",
"gradle-to-js": "^2.0.1",
"i18next": "^24.2.3",
"isomorphic-git": "^1.30.1",
"isomorphic-unzip": "^1.1.5",
"node-fetch": "^2.6.1",
"plist": "^3.1.0",
"progress": "^2.0.3",
"properties": "^1.2.1",
"read": "^4.1.0",
"semver": "^7.7.1",
"read": "^1.0.7",
"request": "^2.88.2",
"tcp-ping": "^0.1.1",
"tty-table": "4.2",
"update-notifier": "^5.1.0",
"yauzl": "^3.2.0",
"yazl": "3.3.1"
},
"engines": {
"node": ">= 14"
"yauzl": "^2.10.0",
"yazl": "2.5.1"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.11.18",
"@types/filesize-parser": "^1.5.3",
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.14.0",
"@types/node-fetch": "^2.6.12",
"@types/progress": "^2.0.7",
"@types/semver": "^7.7.0",
"@types/tcp-ping": "^0.1.6",
"@types/update-notifier": "^6.0.8",
"@types/yauzl": "^2.10.3",
"@types/yazl": "^2.4.6",
"typescript": "^5.8.3"
"babel-cli": "^6.26.0",
"babel-plugin-syntax-object-rest-spread": "^6.13.0",
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
"babel-plugin-transform-es2015-spread": "^6.22.0",
"babel-plugin-transform-object-rest-spread": "^6.26.0"
},
"engines": {
"node": ">= 10"
}
}

3253
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

177
src/api.js Normal file
View File

@@ -0,0 +1,177 @@
import fetch from 'node-fetch';
const defaultEndpoint = 'https://update.reactnative.cn/api';
let host = process.env.PUSHY_REGISTRY || defaultEndpoint;
import fs from 'fs';
import request from 'request';
import ProgressBar from 'progress';
import packageJson from '../package.json';
import tcpp from 'tcp-ping';
import util from 'util';
import path from 'path';
import filesizeParser from 'filesize-parser';
import { pricingPageUrl } from './utils';
const tcpPing = util.promisify(tcpp.ping);
let session = undefined;
let savedSession = undefined;
const userAgent = `react-native-update-cli/${packageJson.version}`;
export const getSession = function () {
return session;
};
export const replaceSession = function (newSession) {
session = newSession;
};
export const loadSession = async function () {
if (fs.existsSync('.update')) {
try {
replaceSession(JSON.parse(fs.readFileSync('.update', 'utf8')));
savedSession = session;
} catch (e) {
console.error(
'Failed to parse file `.update`. Try to remove it manually.',
);
throw e;
}
}
};
export const saveSession = function () {
// Only save on change.
if (session !== savedSession) {
const current = session;
const data = JSON.stringify(current, null, 4);
fs.writeFileSync('.update', data, 'utf8');
savedSession = current;
}
};
export const closeSession = function () {
if (fs.existsSync('.update')) {
fs.unlinkSync('.update');
savedSession = undefined;
}
session = undefined;
host = process.env.PUSHY_REGISTRY || defaultEndpoint;
};
async function query(url, options) {
const resp = await fetch(url, options);
const text = await resp.text();
let json;
try {
json = JSON.parse(text);
} catch (e) {
throw new Error(`Server error: ${text}`);
}
if (resp.status !== 200) {
throw Object.assign(new Error(json.message || json.error), {
status: resp.status,
});
}
return json;
}
function queryWithoutBody(method) {
return function (api) {
return query(host + api, {
method,
headers: {
'User-Agent': userAgent,
'X-AccessToken': session ? session.token : '',
},
});
};
}
function queryWithBody(method) {
return function (api, body) {
return query(host + api, {
method,
headers: {
'User-Agent': userAgent,
'Content-Type': 'application/json',
'X-AccessToken': session ? session.token : '',
},
body: JSON.stringify(body),
});
};
}
export const get = queryWithoutBody('GET');
export const post = queryWithBody('POST');
export const put = queryWithBody('PUT');
export const doDelete = queryWithBody('DELETE');
export async function uploadFile(fn, key) {
const { url, backupUrl, formData, maxSize } = await post('/upload', {
ext: path.extname(fn),
});
let realUrl = url;
if (backupUrl) {
if (global.USE_ACC_OSS) {
realUrl = backupUrl;
} else {
const pingResult = await tcpPing({
address: url.replace('https://', ''),
attempts: 4,
timeout: 1000,
});
// console.log({pingResult});
if (isNaN(pingResult.avg) || pingResult.avg > 150) {
realUrl = backupUrl;
}
}
// console.log({realUrl});
}
const fileSize = fs.statSync(fn).size;
if (maxSize && fileSize > filesizeParser(maxSize)) {
throw new Error(
`此文件大小${(fileSize / 1048576).toFixed(
1,
)}m, 超出当前额度${maxSize}。您可以考虑升级付费业务以提升此额度。详情请访问: ${pricingPageUrl}`,
);
}
const bar = new ProgressBar(' 上传中 [:bar] :percent :etas', {
complete: '=',
incomplete: ' ',
total: fileSize,
});
const info = await new Promise((resolve, reject) => {
if (key) {
formData.key = key;
}
formData.file = fs.createReadStream(fn);
formData.file.on('data', function (data) {
bar.tick(data.length);
});
request.post(
realUrl,
{
formData,
},
(err, resp, body) => {
if (err) {
return reject(err);
}
if (resp.statusCode > 299) {
return reject(
Object.assign(new Error(body), { status: resp.statusCode }),
);
}
resolve({ hash: formData.key });
},
);
});
return info;
}

View File

@@ -1,179 +0,0 @@
import fetch from 'node-fetch';
import fs from 'node:fs';
import util from 'node:util';
import path from 'node:path';
import ProgressBar from 'progress';
import packageJson from '../package.json';
import tcpp from 'tcp-ping';
import filesizeParser from 'filesize-parser';
import {
pricingPageUrl,
credentialFile,
defaultEndpoint,
} from './utils/constants';
import type { Session } from 'types';
import FormData from 'form-data';
import { t } from './utils/i18n';
const tcpPing = util.promisify(tcpp.ping);
let session: Session | undefined;
let savedSession: Session | undefined;
const host =
process.env.PUSHY_REGISTRY || process.env.RNU_API || defaultEndpoint;
const userAgent = `react-native-update-cli/${packageJson.version}`;
export const getSession = () => session;
export const replaceSession = (newSession: { token: string }) => {
session = newSession;
};
export const loadSession = async () => {
if (fs.existsSync(credentialFile)) {
try {
replaceSession(JSON.parse(fs.readFileSync(credentialFile, 'utf8')));
savedSession = session;
} catch (e) {
console.error(
`Failed to parse file ${credentialFile}. Try to remove it manually.`,
);
throw e;
}
}
};
export const saveSession = () => {
// Only save on change.
if (session !== savedSession) {
const current = session;
const data = JSON.stringify(current, null, 4);
fs.writeFileSync(credentialFile, data, 'utf8');
savedSession = current;
}
};
export const closeSession = () => {
if (fs.existsSync(credentialFile)) {
fs.unlinkSync(credentialFile);
savedSession = undefined;
}
session = undefined;
};
async function query(url: string, options: fetch.RequestInit) {
const resp = await fetch(url, options);
const text = await resp.text();
let json: any;
try {
json = JSON.parse(text);
} catch (e) {}
if (resp.status !== 200) {
const message = json?.message || resp.statusText;
if (resp.status === 401) {
throw new Error(t('loginExpired'));
}
throw new Error(message);
}
return json;
}
function queryWithoutBody(method: string) {
return (api: string) =>
query(host + api, {
method,
headers: {
'User-Agent': userAgent,
'X-AccessToken': session ? session.token : '',
},
});
}
function queryWithBody(method: string) {
return (api: string, body?: Record<string, any>) =>
query(host + api, {
method,
headers: {
'User-Agent': userAgent,
'Content-Type': 'application/json',
'X-AccessToken': session ? session.token : '',
},
body: JSON.stringify(body),
});
}
export const get = queryWithoutBody('GET');
export const post = queryWithBody('POST');
export const put = queryWithBody('PUT');
export const doDelete = queryWithBody('DELETE');
export async function uploadFile(fn: string, key?: string) {
const { url, backupUrl, formData, maxSize } = await post('/upload', {
ext: path.extname(fn),
});
let realUrl = url;
if (backupUrl) {
if (global.USE_ACC_OSS) {
realUrl = backupUrl;
} else {
const pingResult = await tcpPing({
address: url.replace('https://', ''),
attempts: 4,
timeout: 1000,
});
// console.log({pingResult});
if (Number.isNaN(pingResult.avg) || pingResult.avg > 150) {
realUrl = backupUrl;
}
}
// console.log({realUrl});
}
const fileSize = fs.statSync(fn).size;
if (maxSize && fileSize > filesizeParser(maxSize)) {
const readableFileSize = `${(fileSize / 1048576).toFixed(1)}m`;
throw new Error(
t('fileSizeExceeded', {
fileSize: readableFileSize,
maxSize,
pricingPageUrl,
}),
);
}
const bar = new ProgressBar(' Uploading [:bar] :percent :etas', {
complete: '=',
incomplete: ' ',
total: fileSize,
});
const form = new FormData();
for (const [k, v] of Object.entries(formData)) {
form.append(k, v);
}
const fileStream = fs.createReadStream(fn);
fileStream.on('data', (data) => {
bar.tick(data.length);
});
if (key) {
form.append('key', key);
}
form.append('file', fileStream);
const res = await fetch(realUrl, {
method: 'POST',
body: form,
});
if (res.status > 299) {
throw new Error(`${res.status}: ${res.statusText}`);
}
// const body = await response.json();
return { hash: key || formData.key };
}

127
src/app.js Normal file
View File

@@ -0,0 +1,127 @@
import { question } from './utils';
import fs from 'fs';
import Table from 'tty-table';
import { post, get, doDelete } from './api';
const validPlatforms = {
ios: 1,
android: 1,
};
export function checkPlatform(platform) {
if (!validPlatforms[platform]) {
throw new Error(`无法识别的平台 '${platform}'`);
}
return platform;
}
export function getSelectedApp(platform) {
checkPlatform(platform);
if (!fs.existsSync('update.json')) {
throw new Error(
`App not selected. run 'pushy selectApp --platform ${platform}' first!`,
);
}
const updateInfo = JSON.parse(fs.readFileSync('update.json', 'utf8'));
if (!updateInfo[platform]) {
throw new Error(
`App not selected. run 'pushy selectApp --platform ${platform}' first!`,
);
}
return updateInfo[platform];
}
export async function listApp(platform) {
const { data } = await get('/app/list');
const list = platform ? data.filter((v) => v.platform === platform) : data;
const header = [
{ value: '应用 id' },
{ value: '应用名称' },
{ value: '平台' },
];
const rows = [];
for (const app of list) {
rows.push([app.id, app.name, app.platform]);
}
console.log(Table(header, rows).render());
if (platform) {
console.log(`\共 ${list.length} ${platform} 个应用`);
} else {
console.log(`\共 ${list.length} 个应用`);
}
return list;
}
export async function chooseApp(platform) {
const list = await listApp(platform);
while (true) {
const id = await question('输入应用 id:');
const app = list.find((v) => v.id === (id | 0));
if (app) {
return app;
}
}
}
export const commands = {
createApp: async function ({ options }) {
const name = options.name || (await question('应用名称:'));
const { downloadUrl } = options;
const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')),
);
const { id } = await post('/app/create', { name, platform });
console.log(`已成功创建应用id: ${id}`);
await this.selectApp({
args: [id],
options: { platform, downloadUrl },
});
},
deleteApp: async function ({ args, options }) {
const { platform } = options;
const id = args[0] || chooseApp(platform);
if (!id) {
console.log('已取消');
}
await doDelete(`/app/${id}`);
console.log('操作成功');
},
apps: async function ({ options }) {
const { platform } = options;
listApp(platform);
},
selectApp: async function ({ args, options }) {
const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')),
);
const id = args[0] ? parseInt(args[0]) : (await chooseApp(platform)).id;
let updateInfo = {};
if (fs.existsSync('update.json')) {
try {
updateInfo = JSON.parse(fs.readFileSync('update.json', 'utf8'));
} catch (e) {
console.error(
'Failed to parse file `update.json`. Try to remove it manually.',
);
throw e;
}
}
const { appKey } = await get(`/app/${id}`);
updateInfo[platform] = {
appId: id,
appKey,
};
fs.writeFileSync(
'update.json',
JSON.stringify(updateInfo, null, 4),
'utf8',
);
},
};

View File

@@ -1,123 +0,0 @@
import { question } from './utils';
import fs from 'node:fs';
import Table from 'tty-table';
import { post, get, doDelete } from './api';
import type { Platform } from './types';
import { t } from './utils/i18n';
const validPlatforms = ['ios', 'android', 'harmony'];
export function checkPlatform(platform: Platform) {
if (!validPlatforms.includes(platform)) {
throw new Error(t('unsupportedPlatform', { platform }));
}
return platform;
}
export function getSelectedApp(platform: Platform) {
checkPlatform(platform);
if (!fs.existsSync('update.json')) {
throw new Error(t('appNotSelected', { platform }));
}
const updateInfo = JSON.parse(fs.readFileSync('update.json', 'utf8'));
if (!updateInfo[platform]) {
throw new Error(t('appNotSelected', { platform }));
}
return updateInfo[platform];
}
export async function listApp(platform: Platform | '' = '') {
const { data } = await get('/app/list');
const list = platform ? data.filter((v) => v.platform === platform) : data;
const header = [
{ value: t('appId') },
{ value: t('appName') },
{ value: t('platform') },
];
const rows = [];
for (const app of list) {
rows.push([app.id, app.name, app.platform]);
}
console.log(Table(header, rows).render());
console.log(`\n${t('totalApps', { count: list.length, platform })}`);
return list;
}
export async function chooseApp(platform: Platform) {
const list = await listApp(platform);
while (true) {
const id = await question(t('enterAppIdQuestion'));
const app = list.find((v) => v.id === Number(id));
if (app) {
return app;
}
}
}
export const commands = {
createApp: async function ({
options,
}: {
options: { name: string; downloadUrl: string; platform: Platform };
}) {
const name = options.name || (await question(t('appNameQuestion')));
const { downloadUrl } = options;
const platform = checkPlatform(
options.platform || (await question(t('platformQuestion'))),
);
const { id } = await post('/app/create', { name, platform, downloadUrl });
console.log(t('createAppSuccess', { id }));
await this.selectApp({
args: [id],
options: { platform },
});
},
deleteApp: async ({ args, options }: { args: string[]; options: { platform: Platform } }) => {
const { platform } = options;
const id = args[0] || chooseApp(platform);
if (!id) {
console.log(t('cancelled'));
}
await doDelete(`/app/${id}`);
console.log(t('operationSuccess'));
},
apps: async ({ options }: { options: { platform: Platform } }) => {
const { platform } = options;
listApp(platform);
},
selectApp: async ({ args, options }: { args: string[]; options: { platform: Platform } }) => {
const platform = checkPlatform(
options.platform || (await question(t('platformQuestion'))),
);
const id = args[0]
? Number.parseInt(args[0])
: (await chooseApp(platform)).id;
let updateInfo: Partial<Record<Platform, { appId: number; appKey: string }>> = {};
if (fs.existsSync('update.json')) {
try {
updateInfo = JSON.parse(fs.readFileSync('update.json', 'utf8'));
} catch (e) {
console.error(t('failedToParseUpdateJson'));
throw e;
}
}
const { appKey } = await get(`/app/${id}`);
updateInfo[platform] = {
appId: id,
appKey,
};
fs.writeFileSync(
'update.json',
JSON.stringify(updateInfo, null, 4),
'utf8',
);
},
};

700
src/bundle.js Normal file
View File

@@ -0,0 +1,700 @@
import path from 'path';
import { getRNVersion, translateOptions } from './utils';
import * as fs from 'fs-extra';
import { ZipFile } from 'yazl';
import { open as openZipFile } from 'yauzl';
import { question, printVersionCommand } from './utils';
import { checkPlatform } from './app';
import { spawn, spawnSync } from 'child_process';
const g2js = require('gradle-to-js/lib/parser');
import os from 'os';
const properties = require('properties');
let bsdiff, hdiff, diff;
try {
bsdiff = require('node-bsdiff').diff;
} catch (e) {}
try {
hdiff = require('node-hdiffpatch').diff;
} catch (e) {}
async function runReactNativeBundleCommand(
bundleName,
development,
entryFile,
outputFolder,
platform,
sourcemapOutput,
config,
) {
let gradleConfig = {};
if (platform === 'android') {
gradleConfig = await checkGradleConfig();
if (gradleConfig.crunchPngs !== false) {
console.warn(
'android的crunchPngs选项似乎尚未禁用如已禁用则请忽略此提示这可能导致热更包体积异常增大具体请参考 https://pushy.reactnative.cn/docs/getting-started.html#%E7%A6%81%E7%94%A8-android-%E7%9A%84-crunch-%E4%BC%98%E5%8C%96 \n',
);
}
}
let reactNativeBundleArgs = [];
let envArgs = process.env.PUSHY_ENV_ARGS;
if (envArgs) {
Array.prototype.push.apply(
reactNativeBundleArgs,
envArgs.trim().split(/\s+/),
);
}
fs.emptyDirSync(outputFolder);
let cliPath = require.resolve('react-native/local-cli/cli.js', {
paths: [process.cwd()],
});
try {
require.resolve('expo-router', {
paths: [process.cwd()],
});
console.log(`expo-router detected, will use @expo/cli to bundle.\n`);
// if using expo-router, use expo-cli
cliPath = require.resolve('@expo/cli', {
paths: [process.cwd()],
});
} catch (e) {}
const bundleCommand = cliPath.includes('@expo/cli')
? 'export:embed'
: 'bundle';
Array.prototype.push.apply(reactNativeBundleArgs, [
cliPath,
bundleCommand,
'--assets-dest',
outputFolder,
'--bundle-output',
path.join(outputFolder, bundleName),
'--dev',
development,
'--entry-file',
entryFile,
'--platform',
platform,
'--reset-cache',
]);
if (sourcemapOutput) {
reactNativeBundleArgs.push('--sourcemap-output', sourcemapOutput);
}
if (config) {
reactNativeBundleArgs.push('--config', config);
}
const reactNativeBundleProcess = spawn('node', reactNativeBundleArgs);
console.log(
`Running bundle command: node ${reactNativeBundleArgs.join(' ')}`,
);
return new Promise((resolve, reject) => {
reactNativeBundleProcess.stdout.on('data', (data) => {
console.log(data.toString().trim());
});
reactNativeBundleProcess.stderr.on('data', (data) => {
console.error(data.toString().trim());
});
reactNativeBundleProcess.on('close', async (exitCode) => {
if (exitCode) {
reject(
new Error(
`"react-native bundle" command exited with code ${exitCode}.`,
),
);
} else {
let hermesEnabled = false;
if (platform === 'android') {
const gradlePropeties = await new Promise((resolve) => {
properties.parse(
'./android/gradle.properties',
{ path: true },
function (error, props) {
if (error) {
console.error(error);
resolve(null);
}
resolve(props);
},
);
});
hermesEnabled = gradlePropeties.hermesEnabled;
if (typeof hermesEnabled !== 'boolean')
hermesEnabled = gradleConfig.enableHermes;
} else if (
platform === 'ios' &&
fs.existsSync('ios/Pods/hermes-engine')
) {
hermesEnabled = true;
}
if (hermesEnabled) {
await compileHermesByteCode(
bundleName,
outputFolder,
sourcemapOutput,
);
}
resolve(null);
}
});
});
}
function getHermesOSBin() {
if (os.platform() === 'win32') return 'win64-bin';
if (os.platform() === 'darwin') return 'osx-bin';
if (os.platform() === 'linux') return 'linux64-bin';
}
async function checkGradleConfig() {
let enableHermes = false;
let crunchPngs;
try {
const gradleConfig = await g2js.parseFile('android/app/build.gradle');
const projectConfig = gradleConfig['project.ext.react'];
for (const packagerConfig of projectConfig) {
if (
packagerConfig.includes('enableHermes') &&
packagerConfig.includes('true')
) {
enableHermes = true;
break;
}
}
crunchPngs = gradleConfig.android.buildTypes.release.crunchPngs;
} catch (e) {}
return {
enableHermes,
crunchPngs,
};
}
async function compileHermesByteCode(
bundleName,
outputFolder,
sourcemapOutput,
) {
console.log(`Hermes enabled, now compiling to hermes bytecode:\n`);
// >= rn 0.69
const rnDir = path.dirname(
require.resolve('react-native', {
paths: [process.cwd()],
}),
);
let hermesPath = path.join(rnDir, `/sdks/hermesc/${getHermesOSBin()}`);
// < rn 0.69
if (!fs.existsSync(hermesPath)) {
hermesPath = `node_modules/hermes-engine/${getHermesOSBin()}`;
}
const hermesCommand = `${hermesPath}/hermesc`;
const args = [
'-emit-binary',
'-out',
path.join(outputFolder, bundleName),
path.join(outputFolder, bundleName),
'-O',
];
if (sourcemapOutput) {
args.push('-output-source-map');
}
console.log(
'Running hermesc: ' + hermesCommand + ' ' + args.join(' ') + '\n',
);
spawnSync(hermesCommand, args, {
stdio: 'ignore',
});
}
async function pack(dir, output) {
console.log('Packing');
fs.ensureDirSync(path.dirname(output));
await new Promise((resolve, reject) => {
const zipfile = new ZipFile();
function addDirectory(root, rel) {
if (rel) {
zipfile.addEmptyDirectory(rel);
}
const childs = fs.readdirSync(root);
for (const name of childs) {
if (name === '.' || name === '..') {
continue;
}
const fullPath = path.join(root, name);
const stat = fs.statSync(fullPath);
if (stat.isFile()) {
//console.log('adding: ' + rel+name);
zipfile.addFile(fullPath, rel + name);
} else if (stat.isDirectory()) {
//console.log('adding: ' + rel+name+'/');
addDirectory(fullPath, rel + name + '/');
}
}
}
addDirectory(dir, '');
zipfile.outputStream.on('error', (err) => reject(err));
zipfile.outputStream
.pipe(fs.createWriteStream(output))
.on('close', function () {
resolve();
});
zipfile.end();
});
console.log('ppk热更包已生成并保存到: ' + output);
}
function readEntire(entry, zipFile) {
const buffers = [];
return new Promise((resolve, reject) => {
zipFile.openReadStream(entry, (err, stream) => {
stream.pipe({
write(chunk) {
buffers.push(chunk);
},
end() {
resolve(Buffer.concat(buffers));
},
prependListener() {},
on() {},
once() {},
emit() {},
});
});
});
}
function basename(fn) {
const m = /^(.+\/)[^\/]+\/?$/.exec(fn);
return m && m[1];
}
async function diffFromPPK(origin, next, output) {
fs.ensureDirSync(path.dirname(output));
const originEntries = {};
const originMap = {};
let originSource;
await enumZipEntries(origin, (entry, zipFile) => {
originEntries[entry.fileName] = entry;
if (!/\/$/.test(entry.fileName)) {
// isFile
originMap[entry.crc32] = entry.fileName;
if (entry.fileName === 'index.bundlejs') {
// This is source.
return readEntire(entry, zipFile).then((v) => (originSource = v));
}
}
});
if (!originSource) {
throw new Error(
`Bundle file not found! Please use default bundle file name and path.`,
);
}
const copies = {};
const zipfile = new ZipFile();
const writePromise = new Promise((resolve, reject) => {
zipfile.outputStream.on('error', (err) => {
throw err;
});
zipfile.outputStream
.pipe(fs.createWriteStream(output))
.on('close', function () {
resolve();
});
});
const addedEntry = {};
function addEntry(fn) {
//console.log(fn);
if (!fn || addedEntry[fn]) {
return;
}
const base = basename(fn);
if (base) {
addEntry(base);
}
zipfile.addEmptyDirectory(fn);
}
const newEntries = {};
await enumZipEntries(next, (entry, nextZipfile) => {
newEntries[entry.fileName] = entry;
if (/\/$/.test(entry.fileName)) {
// Directory
if (!originEntries[entry.fileName]) {
addEntry(entry.fileName);
}
} else if (entry.fileName === 'index.bundlejs') {
//console.log('Found bundle');
return readEntire(entry, nextZipfile).then((newSource) => {
//console.log('Begin diff');
zipfile.addBuffer(
diff(originSource, newSource),
'index.bundlejs.patch',
);
//console.log('End diff');
});
} else {
// If same file.
const originEntry = originEntries[entry.fileName];
if (originEntry && originEntry.crc32 === entry.crc32) {
// ignore
return;
}
// If moved from other place
if (originMap[entry.crc32]) {
const base = basename(entry.fileName);
if (!originEntries[base]) {
addEntry(base);
}
copies[entry.fileName] = originMap[entry.crc32];
return;
}
// New file.
addEntry(basename(entry.fileName));
return new Promise((resolve, reject) => {
nextZipfile.openReadStream(entry, function (err, readStream) {
if (err) {
return reject(err);
}
zipfile.addReadStream(readStream, entry.fileName);
readStream.on('end', () => {
//console.log('add finished');
resolve();
});
});
});
}
});
const deletes = {};
for (let k in originEntries) {
if (!newEntries[k]) {
console.log('Delete ' + k);
deletes[k] = 1;
}
}
//console.log({copies, deletes});
zipfile.addBuffer(
Buffer.from(JSON.stringify({ copies, deletes })),
'__diff.json',
);
zipfile.end();
await writePromise;
}
async function diffFromPackage(
origin,
next,
output,
originBundleName,
transformPackagePath = (v) => v,
) {
fs.ensureDirSync(path.dirname(output));
const originEntries = {};
const originMap = {};
let originSource;
await enumZipEntries(origin, (entry, zipFile) => {
if (!/\/$/.test(entry.fileName)) {
const fn = transformPackagePath(entry.fileName);
if (!fn) {
return;
}
//console.log(fn);
// isFile
originEntries[fn] = entry.crc32;
originMap[entry.crc32] = fn;
if (fn === originBundleName) {
// This is source.
return readEntire(entry, zipFile).then((v) => (originSource = v));
}
}
});
if (!originSource) {
throw new Error(
`Bundle file not found! Please use default bundle file name and path.`,
);
}
const copies = {};
const zipfile = new ZipFile();
const writePromise = new Promise((resolve, reject) => {
zipfile.outputStream.on('error', (err) => {
throw err;
});
zipfile.outputStream
.pipe(fs.createWriteStream(output))
.on('close', function () {
resolve();
});
});
await enumZipEntries(next, (entry, nextZipfile) => {
if (/\/$/.test(entry.fileName)) {
// Directory
zipfile.addEmptyDirectory(entry.fileName);
} else if (entry.fileName === 'index.bundlejs') {
//console.log('Found bundle');
return readEntire(entry, nextZipfile).then((newSource) => {
//console.log('Begin diff');
zipfile.addBuffer(
diff(originSource, newSource),
'index.bundlejs.patch',
);
//console.log('End diff');
});
} else {
// If same file.
if (originEntries[entry.fileName] === entry.crc32) {
copies[entry.fileName] = '';
return;
}
// If moved from other place
if (originMap[entry.crc32]) {
copies[entry.fileName] = originMap[entry.crc32];
return;
}
return new Promise((resolve, reject) => {
nextZipfile.openReadStream(entry, function (err, readStream) {
if (err) {
return reject(err);
}
zipfile.addReadStream(readStream, entry.fileName);
readStream.on('end', () => {
//console.log('add finished');
resolve();
});
});
});
}
});
zipfile.addBuffer(Buffer.from(JSON.stringify({ copies })), '__diff.json');
zipfile.end();
await writePromise;
}
function enumZipEntries(zipFn, callback) {
return new Promise((resolve, reject) => {
openZipFile(zipFn, { lazyEntries: true }, (err, zipfile) => {
if (err) {
return reject(err);
}
zipfile.on('end', resolve);
zipfile.on('error', reject);
zipfile.on('entry', (entry) => {
const result = callback(entry, zipfile);
if (result && typeof result.then === 'function') {
result.then(() => zipfile.readEntry());
} else {
zipfile.readEntry();
}
});
zipfile.readEntry();
});
});
}
function diffArgsCheck(args, options, diffFn) {
const [origin, next] = args;
if (!origin || !next) {
console.error(`Usage: pushy ${diffFn} <origin> <next>`);
process.exit(1);
}
if (diffFn.startsWith('hdiff')) {
if (!hdiff) {
console.error(
`This function needs "node-hdiffpatch".
Please run "npm i node-hdiffpatch" to install`,
);
process.exit(1);
}
diff = hdiff;
} else {
if (!bsdiff) {
console.error(
`This function needs "node-bsdiff".
Please run "npm i node-bsdiff" to install`,
);
process.exit(1);
}
diff = bsdiff;
}
const { output } = options;
return {
origin,
next,
realOutput: output.replace(/\$\{time\}/g, '' + Date.now()),
};
}
export const commands = {
bundle: async function ({ options }) {
const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')),
);
let { bundleName, entryFile, intermediaDir, output, dev, sourcemap } =
translateOptions({
...options,
platform,
});
const sourcemapOutput = path.join(intermediaDir, bundleName + '.map');
const realOutput = output.replace(/\$\{time\}/g, '' + Date.now());
if (!platform) {
throw new Error('Platform must be specified.');
}
const { version, major, minor } = getRNVersion();
console.log('Bundling with react-native: ', version);
printVersionCommand();
await runReactNativeBundleCommand(
bundleName,
dev,
entryFile,
intermediaDir,
platform,
sourcemap ? sourcemapOutput : '',
);
await pack(path.resolve(intermediaDir), realOutput);
const v = await question('是否现在上传此热更包?(Y/N)');
if (v.toLowerCase() === 'y') {
await this.publish({
args: [realOutput],
options: {
platform,
},
});
}
},
async diff({ args, options }) {
const { origin, next, realOutput } = diffArgsCheck(args, options, 'diff');
await diffFromPPK(origin, next, realOutput, 'index.bundlejs');
console.log(`${realOutput} generated.`);
},
async hdiff({ args, options }) {
const { origin, next, realOutput } = diffArgsCheck(args, options, 'hdiff');
await diffFromPPK(origin, next, realOutput, 'index.bundlejs');
console.log(`${realOutput} generated.`);
},
async diffFromApk({ args, options }) {
const { origin, next, realOutput } = diffArgsCheck(
args,
options,
'diffFromApk',
);
await diffFromPackage(
origin,
next,
realOutput,
'assets/index.android.bundle',
);
console.log(`${realOutput} generated.`);
},
async hdiffFromApk({ args, options }) {
const { origin, next, realOutput } = diffArgsCheck(
args,
options,
'hdiffFromApk',
);
await diffFromPackage(
origin,
next,
realOutput,
'assets/index.android.bundle',
);
console.log(`${realOutput} generated.`);
},
async diffFromIpa({ args, options }) {
const { origin, next, realOutput } = diffArgsCheck(
args,
options,
'diffFromIpa',
);
await diffFromPackage(origin, next, realOutput, 'main.jsbundle', (v) => {
const m = /^Payload\/[^/]+\/(.+)$/.exec(v);
return m && m[1];
});
console.log(`${realOutput} generated.`);
},
async hdiffFromIpa({ args, options }) {
const { origin, next, realOutput } = diffArgsCheck(
args,
options,
'hdiffFromIpa',
);
await diffFromPackage(origin, next, realOutput, 'main.jsbundle', (v) => {
const m = /^Payload\/[^/]+\/(.+)$/.exec(v);
return m && m[1];
});
console.log(`${realOutput} generated.`);
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -2,22 +2,18 @@
import { loadSession } from './api';
import updateNotifier from 'update-notifier';
import { printVersionCommand } from './utils';
import { printVersionCommand } from './utils/index.js';
import pkg from '../package.json';
import { t } from './utils/i18n';
updateNotifier({ pkg }).notify({
isGlobal: true,
message: t('updateNotifier'),
});
updateNotifier({ pkg }).notify({ isGlobal: true });
function printUsage() {
function printUsage({ args }) {
// const commandName = args[0];
// TODO: print usage of commandName, or print global usage.
console.log('Usage is under development now.');
console.log(
'Visit `https://github.com/reactnativecn/react-native-update` for document.',
'Visit `https://github.com/reactnativecn/react-native-pushy` for early document.',
);
process.exit(1);
}
@@ -31,21 +27,21 @@ const commands = {
help: printUsage,
};
async function run() {
await printVersionCommand();
function run() {
if (process.argv.indexOf('-v') >= 0 || process.argv[2] === 'version') {
printVersionCommand();
process.exit();
}
const argv = require('cli-arguments').parse(require('../cli.json'));
global.NO_INTERACTIVE = argv.options['no-interactive'];
global.USE_ACC_OSS = argv.options.acc;
global.USE_ACC_OSS = argv.options['acc'];
loadSession()
.then(() => commands[argv.command](argv))
.catch((err) => {
if (err.status === 401) {
console.log(t('loginFirst'));
console.log('尚未登录。\n请在项目目录中运行`pushy login`命令来登录');
return;
}
console.error(err.stack);

View File

@@ -1,43 +0,0 @@
export default {
updateNotifier:
'Run `{updateCommand}` to update the CLI to get continuous improvements in features, performance, and security.',
loginFirst:
'Not logged in.\nPlease run `cresc login` in the project directory to login.',
lockNotFound:
'No lock file detected, which may cause inconsistent dependencies and hot-updating issues.',
multipleLocksFound:
'Multiple lock files detected ({{lockFiles}}), which may cause inconsistent dependencies and hot-updating issues.',
lockBestPractice: `
Best practices for lock files:
1. All members of the development team should use the same package manager to maintain a single lock file.
2. Add the lock file to version control (but do not commit multiple lock files of different formats).
3. Pay attention to changes in the lock file during code review.
This can reduce the risk of inconsistent dependencies and supply chain attacks.
`,
loginExpired:
'Login information has expired. Please use `cresc login` command to re-login',
fileSizeExceeded:
'This file size is {{fileSize}} , exceeding the current quota {{maxSize}} . You may consider upgrading to a higher plan to increase this quota. Details can be found at: {{pricingPageUrl}}',
bundleNotFound:
'Bundle file not found. Please ensure that this {{packageType}} is a release version and the bundle file name is the default `{{entryFile}}`',
buildTimeNotFound:
'Cannot get the build timestamp of this package. Please update `react-native-update` to the latest version and re-package and upload.',
latestVersionTag: '(latest: {{version}})',
rnuVersionNotFound:
'react-native-update: Cannot get the version number. Please run the command in the project directory',
unsupportedPlatform: 'Unsupported platform `{{platform}}`',
appId: 'App ID',
appName: 'App Name',
platform: 'Platform',
totalApps: 'Total {{count}} apps',
appNotSelected:
'App not selected. run `cresc selectApp --platform {{platform}}` first!',
enterAppIdQuestion: 'Enter AppId:',
appNameQuestion: 'App Name:',
platformQuestion: 'Platform(ios/android/harmony):',
createAppSuccess: 'App created successfully (id: {{id}})',
cancelled: 'Cancelled',
operationSuccess: 'Operation successful',
failedToParseUpdateJson: 'Failed to parse file `update.json`. Try to remove it manually.',
ppkPackageGenerated: 'ppk package generated and saved to: {{- output}}',
};

View File

@@ -1,41 +0,0 @@
export default {
updateNotifier:
'建议运行 `{updateCommand}` 来更新命令行工具以获得功能、性能和安全性的持续改进',
loginFirst: '尚未登录。\n请在项目目录中运行`pushy login`命令来登录',
lockNotFound:
'没有检测到任何 lock 文件,这可能导致依赖关系不一致而使热更异常。',
lockBestPractice: `
关于 lock 文件的最佳实践:
1. 开发团队中的所有成员应该使用相同的包管理器,维护同一份 lock 文件。
2. 将 lock 文件添加到版本控制中(但不要同时提交多种不同格式的 lock 文件)。
3. 代码审核时应关注 lock 文件的变化。
这样可以最大限度避免因依赖关系不一致而导致的热更异常,也降低供应链攻击等安全隐患。
`,
multipleLocksFound:
'检测到多种不同格式的锁文件({{lockFiles}}),这可能导致依赖关系不一致而使热更异常。',
loginExpired: '登录信息已过期,请使用 `pushy login` 命令重新登录',
fileSizeExceeded:
'此文件大小 {{fileSize}} , 超出当前额度 {{maxSize}} 。您可以考虑升级付费业务以提升此额度。详情请访问: {{pricingPageUrl}}',
bundleNotFound:
'找不到 bundle 文件。请确保此 {{packageType}} 为 release 版本,且 bundle 文件名为默认的 `{{entryFile}}`',
buildTimeNotFound:
'无法获取此包的编译时间戳。请更新 `react-native-update` 到最新版本后重新打包上传。',
latestVersionTag: '(最新:{{version}}',
rnuVersionNotFound:
'react-native-update: 无法获取版本号。请在项目目录中运行命令',
unsupportedPlatform: '无法识别的平台 `{{platform}}`',
appId: '应用 id',
appName: '应用名称',
platform: '平台',
totalApps: '共 {{count}} 个{{platform}}应用',
appNotSelected:
'尚未选择应用。请先运行 `pushy selectApp --platform {{platform}}` 来选择应用',
enterAppIdQuestion: '输入应用 id:',
appNameQuestion: '应用名称:',
platformQuestion: '平台(ios/android/harmony):',
createAppSuccess: '已成功创建应用id: {{id}}',
cancelled: '已取消',
operationSuccess: '操作成功',
failedToParseUpdateJson: '无法解析文件 `update.json`。请手动删除它。',
ppkPackageGenerated: 'ppk 热更包已生成并保存到: {{- output}}',
};

View File

@@ -3,13 +3,10 @@ import { question, saveToLocal } from './utils';
import { checkPlatform, getSelectedApp } from './app';
import { getApkInfo, getIpaInfo, getAppInfo } from './utils';
import { getApkInfo, getIpaInfo } from './utils';
import Table from 'tty-table';
import { depVersions } from './utils/dep-versions';
import { getCommitInfo } from './utils/git';
import type { Platform } from 'types';
export async function listPackage(appId: string) {
export async function listPackage(appId) {
const { data } = await get(`/app/${appId}/package/list?limit=1000`);
const header = [{ value: '原生包 Id' }, { value: '原生版本' }];
@@ -38,12 +35,12 @@ export async function listPackage(appId: string) {
return data;
}
export async function choosePackage(appId: string) {
export async function choosePackage(appId) {
const list = await listPackage(appId);
while (true) {
const id = await question('输入原生包 id:');
const app = list.find((v) => v.id === Number(id));
const app = list.find((v) => v.id === (id | 0));
if (app) {
return app;
}
@@ -51,7 +48,7 @@ export async function choosePackage(appId: string) {
}
export const commands = {
uploadIpa: async ({ args }: { args: string[] }) => {
uploadIpa: async function ({ args }) {
const fn = args[0];
if (!fn || !fn.endsWith('.ipa')) {
throw new Error('使用方法: pushy uploadIpa ipa后缀文件');
@@ -82,15 +79,13 @@ export const commands = {
name: versionName,
hash,
buildTime,
deps: depVersions,
commit: await getCommitInfo(),
});
saveToLocal(fn, `${appId}/package/${id}.ipa`);
console.log(
`已成功上传ipa原生包id: ${id}, version: ${versionName}, buildTime: ${buildTime}`,
);
},
uploadApk: async ({ args }: { args: string[] }) => {
uploadApk: async function ({ args }) {
const fn = args[0];
if (!fn || !fn.endsWith('.apk')) {
throw new Error('使用方法: pushy uploadApk apk后缀文件');
@@ -121,77 +116,29 @@ export const commands = {
name: versionName,
hash,
buildTime,
deps: depVersions,
commit: await getCommitInfo(),
});
saveToLocal(fn, `${appId}/package/${id}.apk`);
console.log(
`已成功上传apk原生包id: ${id}, version: ${versionName}, buildTime: ${buildTime}`,
);
},
uploadApp: async ({ args }: { args: string[] }) => {
const fn = args[0];
if (!fn || !fn.endsWith('.app')) {
throw new Error('使用方法: pushy uploadApp app后缀文件');
}
const {
versionName,
buildTime,
appId: appIdInPkg,
appKey: appKeyInPkg,
} = await getAppInfo(fn);
const { appId, appKey } = await getSelectedApp('harmony');
if (appIdInPkg && appIdInPkg != appId) {
throw new Error(
`appId不匹配当前app: ${appIdInPkg}, 当前update.json: ${appId}`,
);
}
if (appKeyInPkg && appKeyInPkg !== appKey) {
throw new Error(
`appKey不匹配当前app: ${appKeyInPkg}, 当前update.json: ${appKey}`,
);
}
const { hash } = await uploadFile(fn);
const { id } = await post(`/app/${appId}/package/create`, {
name: versionName,
hash,
buildTime,
deps: depVersions,
commit: await getCommitInfo(),
});
saveToLocal(fn, `${appId}/package/${id}.app`);
console.log(
`已成功上传app原生包id: ${id}, version: ${versionName}, buildTime: ${buildTime}`,
);
},
parseApp: async ({ args }: { args: string[] }) => {
const fn = args[0];
if (!fn || !fn.endsWith('.app')) {
throw new Error('使用方法: pushy parseApp app后缀文件');
}
console.log(await getAppInfo(fn));
},
parseIpa: async ({ args }: { args: string[] }) => {
parseIpa: async function ({ args }) {
const fn = args[0];
if (!fn || !fn.endsWith('.ipa')) {
throw new Error('使用方法: pushy parseIpa ipa后缀文件');
}
console.log(await getIpaInfo(fn));
},
parseApk: async ({ args }: { args: string[] }) => {
parseApk: async function ({ args }) {
const fn = args[0];
if (!fn || !fn.endsWith('.apk')) {
throw new Error('使用方法: pushy parseApk apk后缀文件');
}
console.log(await getApkInfo(fn));
},
packages: async ({ options }: { options: { platform: Platform } }) => {
packages: async function ({ options }) {
const platform = checkPlatform(
options.platform || (await question('平台(ios/android/harmony):')),
options.platform || (await question('平台(ios/android):')),
);
const { appId } = await getSelectedApp(platform);
await listPackage(appId);

View File

@@ -1,10 +0,0 @@
declare global {
var NO_INTERACTIVE: boolean;
var USE_ACC_OSS: boolean;
}
export interface Session {
token: string;
}
export type Platform = 'ios' | 'android' | 'harmony';

View File

@@ -1,13 +1,13 @@
import { question } from './utils';
import { post, get, replaceSession, saveSession, closeSession } from './api';
import crypto from 'node:crypto';
import crypto from 'crypto';
function md5(str: string) {
function md5(str) {
return crypto.createHash('md5').update(str).digest('hex');
}
export const commands = {
login: async ({ args }: { args: string[] }) => {
login: async function ({ args }) {
const email = args[0] || (await question('email:'));
const pwd = args[1] || (await question('password:', true));
const { token, info } = await post('/user/login', {
@@ -18,11 +18,11 @@ export const commands = {
await saveSession();
console.log(`欢迎使用 pushy 热更新服务, ${info.name}.`);
},
logout: async () => {
logout: async function () {
await closeSession();
console.log('已退出登录');
},
me: async () => {
me: async function () {
const me = await get('/user/me');
for (const k in me) {
if (k !== 'ok') {

View File

@@ -1,34 +0,0 @@
import fs from 'node:fs';
// import path from 'node:path';
import { credentialFile, tempDir } from './constants';
export function addGitIgnore() {
const shouldIgnore = [credentialFile, tempDir];
const gitignorePath = '.gitignore';
if (!fs.existsSync(gitignorePath)) {
return;
}
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8');
const gitignoreLines = gitignoreContent.split('\n');
for (const line of gitignoreLines) {
const index = shouldIgnore.indexOf(line.trim());
if (index !== -1) {
shouldIgnore.splice(index, 1);
}
}
if (shouldIgnore.length > 0) {
gitignoreLines.push('# react-native-update');
for (const line of shouldIgnore) {
gitignoreLines.push(line);
console.log(`Added ${line} to .gitignore`);
}
fs.writeFileSync(gitignorePath, gitignoreLines.join('\n'));
}
}

View File

@@ -1,90 +0,0 @@
const Zip = require('./zip')
const { mapInfoResource, findApkIconPath, getBase64FromBuffer } = require('./utils')
const ManifestName = /^androidmanifest\.xml$/
const ResourceName = /^resources\.arsc$/
const ManifestXmlParser = require('./xml-parser/manifest')
const ResourceFinder = require('./resource-finder')
class ApkParser extends Zip {
/**
* parser for parsing .apk file
* @param {String | File | Blob} file // file's path in Node, instance of File or Blob in Browser
*/
constructor (file) {
super(file)
if (!(this instanceof ApkParser)) {
return new ApkParser(file)
}
}
parse () {
return new Promise((resolve, reject) => {
this.getEntries([ManifestName, ResourceName]).then(buffers => {
if (!buffers[ManifestName]) {
throw new Error('AndroidManifest.xml can\'t be found.')
}
let apkInfo = this._parseManifest(buffers[ManifestName])
let resourceMap
if (!buffers[ResourceName]) {
resolve(apkInfo)
} else {
// parse resourceMap
resourceMap = this._parseResourceMap(buffers[ResourceName])
// update apkInfo with resourceMap
apkInfo = mapInfoResource(apkInfo, resourceMap)
// find icon path and parse icon
const iconPath = findApkIconPath(apkInfo)
if (iconPath) {
this.getEntry(iconPath).then(iconBuffer => {
apkInfo.icon = iconBuffer ? getBase64FromBuffer(iconBuffer) : null
resolve(apkInfo)
}).catch(e => {
apkInfo.icon = null
resolve(apkInfo)
console.warn('[Warning] failed to parse icon: ', e)
})
} else {
apkInfo.icon = null
resolve(apkInfo)
}
}
}).catch(e => {
reject(e)
})
})
}
/**
* Parse manifest
* @param {Buffer} buffer // manifest file's buffer
*/
_parseManifest (buffer) {
try {
const parser = new ManifestXmlParser(buffer, {
ignore: [
'application.activity',
'application.service',
'application.receiver',
'application.provider',
'permission-group'
]
})
return parser.parse()
} catch (e) {
throw new Error('Parse AndroidManifest.xml error: ', e)
}
}
/**
* Parse resourceMap
* @param {Buffer} buffer // resourceMap file's buffer
*/
_parseResourceMap (buffer) {
try {
return new ResourceFinder().processResourceTable(buffer)
} catch (e) {
throw new Error('Parser resources.arsc error: ' + e)
}
}
}
module.exports = ApkParser

View File

@@ -1,16 +0,0 @@
const Zip = require('./zip')
class AppParser extends Zip {
/**
* parser for parsing .apk file
* @param {String | File | Blob} file // file's path in Node, instance of File or Blob in Browser
*/
constructor (file) {
super(file)
if (!(this instanceof AppParser)) {
return new AppParser(file)
}
}
}
module.exports = AppParser

View File

@@ -1,45 +0,0 @@
const ApkParser = require('./apk');
const IpaParser = require('./ipa');
const AppParser = require('./app');
const supportFileTypes = ['ipa', 'apk', 'app'];
class AppInfoParser {
file: string | File;
parser: any;
/**
* parser for parsing .ipa or .apk file
* @param {String | File} file // file's path in Node, instance of File in Browser
*/
constructor(file: string | File) {
if (!file) {
throw new Error(
"Param miss: file(file's path in Node, instance of File in browser).",
);
}
const splits = (typeof file === 'string' ? file : file.name).split('.');
const fileType = splits[splits.length - 1].toLowerCase();
if (!supportFileTypes.includes(fileType)) {
throw new Error(
'Unsupported file type, only support .ipa or .apk or .app file.',
);
}
this.file = file;
switch (fileType) {
case 'ipa':
this.parser = new IpaParser(this.file);
break;
case 'apk':
this.parser = new ApkParser(this.file);
break;
case 'app':
this.parser = new AppParser(this.file);
break;
}
}
parse() {
return this.parser.parse();
}
}
export default AppInfoParser;

View File

@@ -1,92 +0,0 @@
const parsePlist = require('plist').parse
const parseBplist = require('bplist-parser').parseBuffer
const cgbiToPng = require('cgbi-to-png')
const Zip = require('./zip')
const { findIpaIconPath, getBase64FromBuffer, isBrowser } = require('./utils')
const PlistName = new RegExp('payload/[^/]+?.app/info.plist$', 'i')
const ProvisionName = /payload\/.+?\.app\/embedded.mobileprovision/
class IpaParser extends Zip {
/**
* parser for parsing .ipa file
* @param {String | File | Blob} file // file's path in Node, instance of File or Blob in Browser
*/
constructor (file) {
super(file)
if (!(this instanceof IpaParser)) {
return new IpaParser(file)
}
}
parse () {
return new Promise((resolve, reject) => {
this.getEntries([PlistName, ProvisionName]).then(buffers => {
if (!buffers[PlistName]) {
throw new Error('Info.plist can\'t be found.')
}
const plistInfo = this._parsePlist(buffers[PlistName])
// parse mobile provision
const provisionInfo = this._parseProvision(buffers[ProvisionName])
plistInfo.mobileProvision = provisionInfo
// find icon path and parse icon
const iconRegex = new RegExp(findIpaIconPath(plistInfo).toLowerCase())
this.getEntry(iconRegex).then(iconBuffer => {
try {
// In general, the ipa file's icon has been specially processed, should be converted
plistInfo.icon = iconBuffer ? getBase64FromBuffer(cgbiToPng.revert(iconBuffer)) : null
} catch (err) {
if (isBrowser()) {
// Normal conversion in other cases
plistInfo.icon = iconBuffer ? getBase64FromBuffer(window.btoa(String.fromCharCode(...iconBuffer))) : null
} else {
plistInfo.icon = null
console.warn('[Warning] failed to parse icon: ', err)
}
}
resolve(plistInfo)
}).catch(e => {
reject(e)
})
}).catch(e => {
reject(e)
})
})
}
/**
* Parse plist
* @param {Buffer} buffer // plist file's buffer
*/
_parsePlist (buffer) {
let result
const bufferType = buffer[0]
if (bufferType === 60 || bufferType === '<' || bufferType === 239) {
result = parsePlist(buffer.toString())
} else if (bufferType === 98) {
result = parseBplist(buffer)[0]
} else {
throw new Error('Unknown plist buffer type.')
}
return result
}
/**
* parse provision
* @param {Buffer} buffer // provision file's buffer
*/
_parseProvision (buffer) {
let info = {}
if (buffer) {
let content = buffer.toString('utf-8')
const firstIndex = content.indexOf('<?xml')
const endIndex = content.indexOf('</plist>')
content = content.slice(firstIndex, endIndex + 8)
if (content) {
info = parsePlist(content)
}
}
return info
}
}
module.exports = IpaParser

View File

@@ -1,499 +0,0 @@
/**
* Code translated from a C# project https://github.com/hylander0/Iteedee.ApkReader/blob/master/Iteedee.ApkReader/ApkResourceFinder.cs
*
* Decode binary file `resources.arsc` from a .apk file to a JavaScript Object.
*/
var ByteBuffer = require("bytebuffer");
var DEBUG = false;
var RES_STRING_POOL_TYPE = 0x0001;
var RES_TABLE_TYPE = 0x0002;
var RES_TABLE_PACKAGE_TYPE = 0x0200;
var RES_TABLE_TYPE_TYPE = 0x0201;
var RES_TABLE_TYPE_SPEC_TYPE = 0x0202;
// The 'data' holds a ResTable_ref, a reference to another resource
// table entry.
var TYPE_REFERENCE = 0x01;
// The 'data' holds an index into the containing resource table's
// global value string pool.
var TYPE_STRING = 0x03;
function ResourceFinder() {
this.valueStringPool = null;
this.typeStringPool = null;
this.keyStringPool = null;
this.package_id = 0;
this.responseMap = {};
this.entryMap = {};
}
/**
* Same to C# BinaryReader.readBytes
*
* @param bb ByteBuffer
* @param len length
* @returns {Buffer}
*/
ResourceFinder.readBytes = function(bb, len) {
var uint8Array = new Uint8Array(len);
for (var i = 0; i < len; i++) {
uint8Array[i] = bb.readUint8();
}
return ByteBuffer.wrap(uint8Array, "binary", true);
};
//
/**
*
* @param {ByteBuffer} bb
* @return {Map<String, Set<String>>}
*/
ResourceFinder.prototype.processResourceTable = function(resourceBuffer) {
const bb = ByteBuffer.wrap(resourceBuffer, "binary", true);
// Resource table structure
var type = bb.readShort(),
headerSize = bb.readShort(),
size = bb.readInt(),
packageCount = bb.readInt(),
buffer,
bb2;
if (type != RES_TABLE_TYPE) {
throw new Error("No RES_TABLE_TYPE found!");
}
if (size != bb.limit) {
throw new Error("The buffer size not matches to the resource table size.");
}
bb.offset = headerSize;
var realStringPoolCount = 0,
realPackageCount = 0;
while (true) {
var pos, t, hs, s;
try {
pos = bb.offset;
t = bb.readShort();
hs = bb.readShort();
s = bb.readInt();
} catch (e) {
break;
}
if (t == RES_STRING_POOL_TYPE) {
// Process the string pool
if (realStringPoolCount == 0) {
// Only the first string pool is processed.
if (DEBUG) {
console.log("Processing the string pool ...");
}
buffer = new ByteBuffer(s);
bb.offset = pos;
bb.prependTo(buffer);
bb2 = ByteBuffer.wrap(buffer, "binary", true);
bb2.LE();
this.valueStringPool = this.processStringPool(bb2);
}
realStringPoolCount++;
} else if (t == RES_TABLE_PACKAGE_TYPE) {
// Process the package
if (DEBUG) {
console.log("Processing the package " + realPackageCount + " ...");
}
buffer = new ByteBuffer(s);
bb.offset = pos;
bb.prependTo(buffer);
bb2 = ByteBuffer.wrap(buffer, "binary", true);
bb2.LE();
this.processPackage(bb2);
realPackageCount++;
} else {
throw new Error("Unsupported type");
}
bb.offset = pos + s;
if (!bb.remaining()) break;
}
if (realStringPoolCount != 1) {
throw new Error("More than 1 string pool found!");
}
if (realPackageCount != packageCount) {
throw new Error("Real package count not equals the declared count.");
}
return this.responseMap;
};
/**
*
* @param {ByteBuffer} bb
*/
ResourceFinder.prototype.processPackage = function(bb) {
// Package structure
var type = bb.readShort(),
headerSize = bb.readShort(),
size = bb.readInt(),
id = bb.readInt();
this.package_id = id;
for (var i = 0; i < 256; ++i) {
bb.readUint8();
}
var typeStrings = bb.readInt(),
lastPublicType = bb.readInt(),
keyStrings = bb.readInt(),
lastPublicKey = bb.readInt();
if (typeStrings != headerSize) {
throw new Error(
"TypeStrings must immediately following the package structure header."
);
}
if (DEBUG) {
console.log("Type strings:");
}
var lastPosition = bb.offset;
bb.offset = typeStrings;
var bbTypeStrings = ResourceFinder.readBytes(bb, bb.limit - bb.offset);
bb.offset = lastPosition;
this.typeStringPool = this.processStringPool(bbTypeStrings);
// Key strings
if (DEBUG) {
console.log("Key strings:");
}
bb.offset = keyStrings;
var key_type = bb.readShort(),
key_headerSize = bb.readShort(),
key_size = bb.readInt();
lastPosition = bb.offset;
bb.offset = keyStrings;
var bbKeyStrings = ResourceFinder.readBytes(bb, bb.limit - bb.offset);
bb.offset = lastPosition;
this.keyStringPool = this.processStringPool(bbKeyStrings);
// Iterate through all chunks
var typeSpecCount = 0;
var typeCount = 0;
bb.offset = keyStrings + key_size;
var bb2;
while (true) {
var pos = bb.offset;
try {
var t = bb.readShort();
var hs = bb.readShort();
var s = bb.readInt();
} catch (e) {
break;
}
if (t == RES_TABLE_TYPE_SPEC_TYPE) {
bb.offset = pos;
bb2 = ResourceFinder.readBytes(bb, s);
this.processTypeSpec(bb2);
typeSpecCount++;
} else if (t == RES_TABLE_TYPE_TYPE) {
bb.offset = pos;
bb2 = ResourceFinder.readBytes(bb, s);
this.processType(bb2);
typeCount++;
}
if (s == 0) {
break;
}
bb.offset = pos + s;
if (!bb.remaining()) {
break;
}
}
};
/**
*
* @param {ByteBuffer} bb
*/
ResourceFinder.prototype.processType = function(bb) {
var type = bb.readShort(),
headerSize = bb.readShort(),
size = bb.readInt(),
id = bb.readByte(),
res0 = bb.readByte(),
res1 = bb.readShort(),
entryCount = bb.readInt(),
entriesStart = bb.readInt();
var refKeys = {};
var config_size = bb.readInt();
// Skip the config data
bb.offset = headerSize;
if (headerSize + entryCount * 4 != entriesStart) {
throw new Error("HeaderSize, entryCount and entriesStart are not valid.");
}
// Start to get entry indices
var entryIndices = new Array(entryCount);
for (var i = 0; i < entryCount; ++i) {
entryIndices[i] = bb.readInt();
}
// Get entries
for (var i = 0; i < entryCount; ++i) {
if (entryIndices[i] == -1) continue;
var resource_id = (this.package_id << 24) | (id << 16) | i;
var pos = bb.offset,
entry_size,
entry_flag,
entry_key,
value_size,
value_res0,
value_dataType,
value_data;
try {
entry_size = bb.readShort()
entry_flag = bb.readShort()
entry_key = bb.readInt()
} catch (e) {
break
}
// Get the value (simple) or map (complex)
var FLAG_COMPLEX = 0x0001;
if ((entry_flag & FLAG_COMPLEX) == 0) {
// Simple case
value_size = bb.readShort();
value_res0 = bb.readByte();
value_dataType = bb.readByte();
value_data = bb.readInt();
var idStr = Number(resource_id).toString(16);
var keyStr = this.keyStringPool[entry_key];
var data = null;
if (DEBUG) {
console.log(
"Entry 0x" + idStr + ", key: " + keyStr + ", simple value type: "
);
}
var key = parseInt(idStr, 16);
var entryArr = this.entryMap[key];
if (entryArr == null) {
entryArr = [];
}
entryArr.push(keyStr);
this.entryMap[key] = entryArr;
if (value_dataType == TYPE_STRING) {
data = this.valueStringPool[value_data];
if (DEBUG) {
console.log(", data: " + this.valueStringPool[value_data] + "");
}
} else if (value_dataType == TYPE_REFERENCE) {
var hexIndex = Number(value_data).toString(16);
refKeys[idStr] = value_data;
} else {
data = "" + value_data;
if (DEBUG) {
console.log(", data: " + value_data + "");
}
}
this.putIntoMap("@" + idStr, data);
} else {
// Complex case
var entry_parent = bb.readInt();
var entry_count = bb.readInt();
for (var j = 0; j < entry_count; ++j) {
var ref_name = bb.readInt();
value_size = bb.readShort();
value_res0 = bb.readByte();
value_dataType = bb.readByte();
value_data = bb.readInt();
}
if (DEBUG) {
console.log(
"Entry 0x" +
Number(resource_id).toString(16) +
", key: " +
this.keyStringPool[entry_key] +
", complex value, not printed."
);
}
}
}
for (var refK in refKeys) {
var values = this.responseMap[
"@" +
Number(refKeys[refK])
.toString(16)
.toUpperCase()
];
if (values != null && Object.keys(values).length < 1000) {
for (var value in values) {
this.putIntoMap("@" + refK, values[value]);
}
}
}
};
/**
*
* @param {ByteBuffer} bb
* @return {Array}
*/
ResourceFinder.prototype.processStringPool = function(bb) {
// String pool structure
//
var type = bb.readShort(),
headerSize = bb.readShort(),
size = bb.readInt(),
stringCount = bb.readInt(),
styleCount = bb.readInt(),
flags = bb.readInt(),
stringsStart = bb.readInt(),
stylesStart = bb.readInt(),
u16len,
buffer;
var isUTF_8 = (flags & 256) != 0;
var offsets = new Array(stringCount);
for (var i = 0; i < stringCount; ++i) {
offsets[i] = bb.readInt();
}
var strings = new Array(stringCount);
for (var i = 0; i < stringCount; ++i) {
var pos = stringsStart + offsets[i];
bb.offset = pos;
strings[i] = "";
if (isUTF_8) {
u16len = bb.readUint8();
if ((u16len & 0x80) != 0) {
u16len = ((u16len & 0x7f) << 8) + bb.readUint8();
}
var u8len = bb.readUint8();
if ((u8len & 0x80) != 0) {
u8len = ((u8len & 0x7f) << 8) + bb.readUint8();
}
if (u8len > 0) {
buffer = ResourceFinder.readBytes(bb, u8len);
try {
strings[i] = ByteBuffer.wrap(buffer, "utf8", true).toString("utf8");
} catch (e) {
if (DEBUG) {
console.error(e);
console.log("Error when turning buffer to utf-8 string.");
}
}
} else {
strings[i] = "";
}
} else {
u16len = bb.readUint16();
if ((u16len & 0x8000) != 0) {
// larger than 32768
u16len = ((u16len & 0x7fff) << 16) + bb.readUint16();
}
if (u16len > 0) {
var len = u16len * 2;
buffer = ResourceFinder.readBytes(bb, len);
try {
strings[i] = ByteBuffer.wrap(buffer, "utf8", true).toString("utf8");
} catch (e) {
if (DEBUG) {
console.error(e);
console.log("Error when turning buffer to utf-8 string.");
}
}
}
}
if (DEBUG) {
console.log("Parsed value: {0}", strings[i]);
}
}
return strings;
};
/**
*
* @param {ByteBuffer} bb
*/
ResourceFinder.prototype.processTypeSpec = function(bb) {
var type = bb.readShort(),
headerSize = bb.readShort(),
size = bb.readInt(),
id = bb.readByte(),
res0 = bb.readByte(),
res1 = bb.readShort(),
entryCount = bb.readInt();
if (DEBUG) {
console.log("Processing type spec " + this.typeStringPool[id - 1] + "...");
}
var flags = new Array(entryCount);
for (var i = 0; i < entryCount; ++i) {
flags[i] = bb.readInt();
}
};
ResourceFinder.prototype.putIntoMap = function(resId, value) {
if (this.responseMap[resId.toUpperCase()] == null) {
this.responseMap[resId.toUpperCase()] = []
}
if(value){
this.responseMap[resId.toUpperCase()].push(value)
}
};
module.exports = ResourceFinder;

View File

@@ -1,167 +0,0 @@
function objectType (o) {
return Object.prototype.toString.call(o).slice(8, -1).toLowerCase()
}
function isArray (o) {
return objectType(o) === 'array'
}
function isObject (o) {
return objectType(o) === 'object'
}
function isPrimitive (o) {
return o === null || ['boolean', 'number', 'string', 'undefined'].includes(objectType(o))
}
function isBrowser () {
return (
typeof process === 'undefined' ||
Object.prototype.toString.call(process) !== '[object process]'
)
}
/**
* map file place with resourceMap
* @param {Object} apkInfo // json info parsed from .apk file
* @param {Object} resourceMap // resourceMap
*/
function mapInfoResource (apkInfo, resourceMap) {
iteratorObj(apkInfo)
return apkInfo
function iteratorObj (obj) {
for (const i in obj) {
if (isArray(obj[i])) {
iteratorArray(obj[i])
} else if (isObject(obj[i])) {
iteratorObj(obj[i])
} else if (isPrimitive(obj[i])) {
if (isResources(obj[i])) {
obj[i] = resourceMap[transKeyToMatchResourceMap(obj[i])]
}
}
}
}
function iteratorArray (array) {
const l = array.length
for (let i = 0; i < l; i++) {
if (isArray(array[i])) {
iteratorArray(array[i])
} else if (isObject(array[i])) {
iteratorObj(array[i])
} else if (isPrimitive(array[i])) {
if (isResources(array[i])) {
array[i] = resourceMap[transKeyToMatchResourceMap(array[i])]
}
}
}
}
function isResources (attrValue) {
if (!attrValue) return false
if (typeof attrValue !== 'string') {
attrValue = attrValue.toString()
}
return attrValue.indexOf('resourceId:') === 0
}
function transKeyToMatchResourceMap (resourceId) {
return '@' + resourceId.replace('resourceId:0x', '').toUpperCase()
}
}
/**
* find .apk file's icon path from json info
* @param info // json info parsed from .apk file
*/
function findApkIconPath (info) {
if (!info.application.icon || !info.application.icon.splice) {
return ''
}
const rulesMap = {
mdpi: 48,
hdpi: 72,
xhdpi: 96,
xxdpi: 144,
xxxhdpi: 192
}
const resultMap = {}
const maxDpiIcon = { dpi: 120, icon: '' }
for (const i in rulesMap) {
info.application.icon.some((icon) => {
if (icon && icon.indexOf(i) !== -1) {
resultMap['application-icon-' + rulesMap[i]] = icon
return true
}
})
// get the maximal size icon
if (
resultMap['application-icon-' + rulesMap[i]] &&
rulesMap[i] >= maxDpiIcon.dpi
) {
maxDpiIcon.dpi = rulesMap[i]
maxDpiIcon.icon = resultMap['application-icon-' + rulesMap[i]]
}
}
if (Object.keys(resultMap).length === 0 || !maxDpiIcon.icon) {
maxDpiIcon.dpi = 120
maxDpiIcon.icon = info.application.icon[0] || ''
resultMap['applicataion-icon-120'] = maxDpiIcon.icon
}
return maxDpiIcon.icon
}
/**
* find .ipa file's icon path from json info
* @param info // json info parsed from .ipa file
*/
function findIpaIconPath (info) {
if (
info.CFBundleIcons &&
info.CFBundleIcons.CFBundlePrimaryIcon &&
info.CFBundleIcons.CFBundlePrimaryIcon.CFBundleIconFiles &&
info.CFBundleIcons.CFBundlePrimaryIcon.CFBundleIconFiles.length
) {
return info.CFBundleIcons.CFBundlePrimaryIcon.CFBundleIconFiles[info.CFBundleIcons.CFBundlePrimaryIcon.CFBundleIconFiles.length - 1]
} else if (info.CFBundleIconFiles && info.CFBundleIconFiles.length) {
return info.CFBundleIconFiles[info.CFBundleIconFiles.length - 1]
} else {
return '.app/Icon.png'
}
}
/**
* transform buffer to base64
* @param {Buffer} buffer
*/
function getBase64FromBuffer (buffer) {
return 'data:image/png;base64,' + buffer.toString('base64')
}
/**
* 去除unicode空字符
* @param {String} str
*/
function decodeNullUnicode (str) {
if (typeof str === 'string') {
// eslint-disable-next-line
str = str.replace(/\u0000/g, '')
}
return str
}
module.exports = {
isArray,
isObject,
isPrimitive,
isBrowser,
mapInfoResource,
findApkIconPath,
findIpaIconPath,
getBase64FromBuffer,
decodeNullUnicode
}

View File

@@ -1,674 +0,0 @@
// From https://github.com/openstf/adbkit-apkreader
const NodeType = {
ELEMENT_NODE: 1,
ATTRIBUTE_NODE: 2,
CDATA_SECTION_NODE: 4
}
const ChunkType = {
NULL: 0x0000,
STRING_POOL: 0x0001,
TABLE: 0x0002,
XML: 0x0003,
XML_FIRST_CHUNK: 0x0100,
XML_START_NAMESPACE: 0x0100,
XML_END_NAMESPACE: 0x0101,
XML_START_ELEMENT: 0x0102,
XML_END_ELEMENT: 0x0103,
XML_CDATA: 0x0104,
XML_LAST_CHUNK: 0x017f,
XML_RESOURCE_MAP: 0x0180,
TABLE_PACKAGE: 0x0200,
TABLE_TYPE: 0x0201,
TABLE_TYPE_SPEC: 0x0202
}
const StringFlags = {
SORTED: 1 << 0,
UTF8: 1 << 8
}
// Taken from android.util.TypedValue
const TypedValue = {
COMPLEX_MANTISSA_MASK: 0x00ffffff,
COMPLEX_MANTISSA_SHIFT: 0x00000008,
COMPLEX_RADIX_0p23: 0x00000003,
COMPLEX_RADIX_16p7: 0x00000001,
COMPLEX_RADIX_23p0: 0x00000000,
COMPLEX_RADIX_8p15: 0x00000002,
COMPLEX_RADIX_MASK: 0x00000003,
COMPLEX_RADIX_SHIFT: 0x00000004,
COMPLEX_UNIT_DIP: 0x00000001,
COMPLEX_UNIT_FRACTION: 0x00000000,
COMPLEX_UNIT_FRACTION_PARENT: 0x00000001,
COMPLEX_UNIT_IN: 0x00000004,
COMPLEX_UNIT_MASK: 0x0000000f,
COMPLEX_UNIT_MM: 0x00000005,
COMPLEX_UNIT_PT: 0x00000003,
COMPLEX_UNIT_PX: 0x00000000,
COMPLEX_UNIT_SHIFT: 0x00000000,
COMPLEX_UNIT_SP: 0x00000002,
DENSITY_DEFAULT: 0x00000000,
DENSITY_NONE: 0x0000ffff,
TYPE_ATTRIBUTE: 0x00000002,
TYPE_DIMENSION: 0x00000005,
TYPE_FIRST_COLOR_INT: 0x0000001c,
TYPE_FIRST_INT: 0x00000010,
TYPE_FLOAT: 0x00000004,
TYPE_FRACTION: 0x00000006,
TYPE_INT_BOOLEAN: 0x00000012,
TYPE_INT_COLOR_ARGB4: 0x0000001e,
TYPE_INT_COLOR_ARGB8: 0x0000001c,
TYPE_INT_COLOR_RGB4: 0x0000001f,
TYPE_INT_COLOR_RGB8: 0x0000001d,
TYPE_INT_DEC: 0x00000010,
TYPE_INT_HEX: 0x00000011,
TYPE_LAST_COLOR_INT: 0x0000001f,
TYPE_LAST_INT: 0x0000001f,
TYPE_NULL: 0x00000000,
TYPE_REFERENCE: 0x00000001,
TYPE_STRING: 0x00000003
}
class BinaryXmlParser {
constructor (buffer, options = {}) {
this.buffer = buffer
this.cursor = 0
this.strings = []
this.resources = []
this.document = null
this.parent = null
this.stack = []
this.debug = options.debug || false
}
readU8 () {
this.debug && console.group('readU8')
this.debug && console.debug('cursor:', this.cursor)
const val = this.buffer[this.cursor]
this.debug && console.debug('value:', val)
this.cursor += 1
this.debug && console.groupEnd()
return val
}
readU16 () {
this.debug && console.group('readU16')
this.debug && console.debug('cursor:', this.cursor)
const val = this.buffer.readUInt16LE(this.cursor)
this.debug && console.debug('value:', val)
this.cursor += 2
this.debug && console.groupEnd()
return val
}
readS32 () {
this.debug && console.group('readS32')
this.debug && console.debug('cursor:', this.cursor)
const val = this.buffer.readInt32LE(this.cursor)
this.debug && console.debug('value:', val)
this.cursor += 4
this.debug && console.groupEnd()
return val
}
readU32 () {
this.debug && console.group('readU32')
this.debug && console.debug('cursor:', this.cursor)
const val = this.buffer.readUInt32LE(this.cursor)
this.debug && console.debug('value:', val)
this.cursor += 4
this.debug && console.groupEnd()
return val
}
readLength8 () {
this.debug && console.group('readLength8')
let len = this.readU8()
if (len & 0x80) {
len = (len & 0x7f) << 8
len += this.readU8()
}
this.debug && console.debug('length:', len)
this.debug && console.groupEnd()
return len
}
readLength16 () {
this.debug && console.group('readLength16')
let len = this.readU16()
if (len & 0x8000) {
len = (len & 0x7fff) << 16
len += this.readU16()
}
this.debug && console.debug('length:', len)
this.debug && console.groupEnd()
return len
}
readDimension () {
this.debug && console.group('readDimension')
const dimension = {
value: null,
unit: null,
rawUnit: null
}
const value = this.readU32()
const unit = dimension.value & 0xff
dimension.value = value >> 8
dimension.rawUnit = unit
switch (unit) {
case TypedValue.COMPLEX_UNIT_MM:
dimension.unit = 'mm'
break
case TypedValue.COMPLEX_UNIT_PX:
dimension.unit = 'px'
break
case TypedValue.COMPLEX_UNIT_DIP:
dimension.unit = 'dp'
break
case TypedValue.COMPLEX_UNIT_SP:
dimension.unit = 'sp'
break
case TypedValue.COMPLEX_UNIT_PT:
dimension.unit = 'pt'
break
case TypedValue.COMPLEX_UNIT_IN:
dimension.unit = 'in'
break
}
this.debug && console.groupEnd()
return dimension
}
readFraction () {
this.debug && console.group('readFraction')
const fraction = {
value: null,
type: null,
rawType: null
}
const value = this.readU32()
const type = value & 0xf
fraction.value = this.convertIntToFloat(value >> 4)
fraction.rawType = type
switch (type) {
case TypedValue.COMPLEX_UNIT_FRACTION:
fraction.type = '%'
break
case TypedValue.COMPLEX_UNIT_FRACTION_PARENT:
fraction.type = '%p'
break
}
this.debug && console.groupEnd()
return fraction
}
readHex24 () {
this.debug && console.group('readHex24')
var val = (this.readU32() & 0xffffff).toString(16)
this.debug && console.groupEnd()
return val
}
readHex32 () {
this.debug && console.group('readHex32')
var val = this.readU32().toString(16)
this.debug && console.groupEnd()
return val
}
readTypedValue () {
this.debug && console.group('readTypedValue')
const typedValue = {
value: null,
type: null,
rawType: null
}
const start = this.cursor
let size = this.readU16()
/* const zero = */ this.readU8()
const dataType = this.readU8()
// Yes, there has been a real world APK where the size is malformed.
if (size === 0) {
size = 8
}
typedValue.rawType = dataType
switch (dataType) {
case TypedValue.TYPE_INT_DEC:
typedValue.value = this.readS32()
typedValue.type = 'int_dec'
break
case TypedValue.TYPE_INT_HEX:
typedValue.value = this.readS32()
typedValue.type = 'int_hex'
break
case TypedValue.TYPE_STRING:
var ref = this.readS32()
typedValue.value = ref > 0 ? this.strings[ref] : ''
typedValue.type = 'string'
break
case TypedValue.TYPE_REFERENCE:
var id = this.readU32()
typedValue.value = `resourceId:0x${id.toString(16)}`
typedValue.type = 'reference'
break
case TypedValue.TYPE_INT_BOOLEAN:
typedValue.value = this.readS32() !== 0
typedValue.type = 'boolean'
break
case TypedValue.TYPE_NULL:
this.readU32()
typedValue.value = null
typedValue.type = 'null'
break
case TypedValue.TYPE_INT_COLOR_RGB8:
typedValue.value = this.readHex24()
typedValue.type = 'rgb8'
break
case TypedValue.TYPE_INT_COLOR_RGB4:
typedValue.value = this.readHex24()
typedValue.type = 'rgb4'
break
case TypedValue.TYPE_INT_COLOR_ARGB8:
typedValue.value = this.readHex32()
typedValue.type = 'argb8'
break
case TypedValue.TYPE_INT_COLOR_ARGB4:
typedValue.value = this.readHex32()
typedValue.type = 'argb4'
break
case TypedValue.TYPE_DIMENSION:
typedValue.value = this.readDimension()
typedValue.type = 'dimension'
break
case TypedValue.TYPE_FRACTION:
typedValue.value = this.readFraction()
typedValue.type = 'fraction'
break
default: {
const type = dataType.toString(16)
console.debug(`Not sure what to do with typed value of type 0x${type}, falling back to reading an uint32.`)
typedValue.value = this.readU32()
typedValue.type = 'unknown'
}
}
// Ensure we consume the whole value
const end = start + size
if (this.cursor !== end) {
const type = dataType.toString(16)
const diff = end - this.cursor
console.debug(`Cursor is off by ${diff} bytes at ${this.cursor} at supposed end \
of typed value of type 0x${type}. The typed value started at offset ${start} \
and is supposed to end at offset ${end}. Ignoring the rest of the value.`)
this.cursor = end
}
this.debug && console.groupEnd()
return typedValue
}
// https://twitter.com/kawasima/status/427730289201139712
convertIntToFloat (int) {
const buf = new ArrayBuffer(4)
;(new Int32Array(buf))[0] = int
return (new Float32Array(buf))[0]
}
readString (encoding) {
this.debug && console.group('readString', encoding)
switch (encoding) {
case 'utf-8':
var stringLength = this.readLength8(encoding)
this.debug && console.debug('stringLength:', stringLength)
var byteLength = this.readLength8(encoding)
this.debug && console.debug('byteLength:', byteLength)
var value = this.buffer.toString(encoding, this.cursor, (this.cursor += byteLength))
this.debug && console.debug('value:', value)
this.debug && console.groupEnd()
return value
case 'ucs2':
stringLength = this.readLength16(encoding)
this.debug && console.debug('stringLength:', stringLength)
byteLength = stringLength * 2
this.debug && console.debug('byteLength:', byteLength)
value = this.buffer.toString(encoding, this.cursor, (this.cursor += byteLength))
this.debug && console.debug('value:', value)
this.debug && console.groupEnd()
return value
default:
throw new Error(`Unsupported encoding '${encoding}'`)
}
}
readChunkHeader () {
this.debug && console.group('readChunkHeader')
var header = {
startOffset: this.cursor,
chunkType: this.readU16(),
headerSize: this.readU16(),
chunkSize: this.readU32()
}
this.debug && console.debug('startOffset:', header.startOffset)
this.debug && console.debug('chunkType:', header.chunkType)
this.debug && console.debug('headerSize:', header.headerSize)
this.debug && console.debug('chunkSize:', header.chunkSize)
this.debug && console.groupEnd()
return header
}
readStringPool (header) {
this.debug && console.group('readStringPool')
header.stringCount = this.readU32()
this.debug && console.debug('stringCount:', header.stringCount)
header.styleCount = this.readU32()
this.debug && console.debug('styleCount:', header.styleCount)
header.flags = this.readU32()
this.debug && console.debug('flags:', header.flags)
header.stringsStart = this.readU32()
this.debug && console.debug('stringsStart:', header.stringsStart)
header.stylesStart = this.readU32()
this.debug && console.debug('stylesStart:', header.stylesStart)
if (header.chunkType !== ChunkType.STRING_POOL) {
throw new Error('Invalid string pool header')
}
const offsets = []
for (let i = 0, l = header.stringCount; i < l; ++i) {
this.debug && console.debug('offset:', i)
offsets.push(this.readU32())
}
const sorted = (header.flags & StringFlags.SORTED) === StringFlags.SORTED
this.debug && console.debug('sorted:', sorted)
const encoding = (header.flags & StringFlags.UTF8) === StringFlags.UTF8
? 'utf-8'
: 'ucs2'
this.debug && console.debug('encoding:', encoding)
const stringsStart = header.startOffset + header.stringsStart
this.cursor = stringsStart
for (let i = 0, l = header.stringCount; i < l; ++i) {
this.debug && console.debug('string:', i)
this.debug && console.debug('offset:', offsets[i])
this.cursor = stringsStart + offsets[i]
this.strings.push(this.readString(encoding))
}
// Skip styles
this.cursor = header.startOffset + header.chunkSize
this.debug && console.groupEnd()
return null
}
readResourceMap (header) {
this.debug && console.group('readResourceMap')
const count = Math.floor((header.chunkSize - header.headerSize) / 4)
for (let i = 0; i < count; ++i) {
this.resources.push(this.readU32())
}
this.debug && console.groupEnd()
return null
}
readXmlNamespaceStart (/* header */) {
this.debug && console.group('readXmlNamespaceStart')
/* const line = */ this.readU32()
/* const commentRef = */ this.readU32()
/* const prefixRef = */ this.readS32()
/* const uriRef = */ this.readS32()
// We don't currently care about the values, but they could
// be accessed like so:
//
// namespaceURI.prefix = this.strings[prefixRef] // if prefixRef > 0
// namespaceURI.uri = this.strings[uriRef] // if uriRef > 0
this.debug && console.groupEnd()
return null
}
readXmlNamespaceEnd (/* header */) {
this.debug && console.group('readXmlNamespaceEnd')
/* const line = */ this.readU32()
/* const commentRef = */ this.readU32()
/* const prefixRef = */ this.readS32()
/* const uriRef = */ this.readS32()
// We don't currently care about the values, but they could
// be accessed like so:
//
// namespaceURI.prefix = this.strings[prefixRef] // if prefixRef > 0
// namespaceURI.uri = this.strings[uriRef] // if uriRef > 0
this.debug && console.groupEnd()
return null
}
readXmlElementStart (/* header */) {
this.debug && console.group('readXmlElementStart')
const node = {
namespaceURI: null,
nodeType: NodeType.ELEMENT_NODE,
nodeName: null,
attributes: [],
childNodes: []
}
/* const line = */ this.readU32()
/* const commentRef = */ this.readU32()
const nsRef = this.readS32()
const nameRef = this.readS32()
if (nsRef > 0) {
node.namespaceURI = this.strings[nsRef]
}
node.nodeName = this.strings[nameRef]
/* const attrStart = */ this.readU16()
/* const attrSize = */ this.readU16()
const attrCount = this.readU16()
/* const idIndex = */ this.readU16()
/* const classIndex = */ this.readU16()
/* const styleIndex = */ this.readU16()
for (let i = 0; i < attrCount; ++i) {
node.attributes.push(this.readXmlAttribute())
}
if (this.document) {
this.parent.childNodes.push(node)
this.parent = node
} else {
this.document = (this.parent = node)
}
this.stack.push(node)
this.debug && console.groupEnd()
return node
}
readXmlAttribute () {
this.debug && console.group('readXmlAttribute')
const attr = {
namespaceURI: null,
nodeType: NodeType.ATTRIBUTE_NODE,
nodeName: null,
name: null,
value: null,
typedValue: null
}
const nsRef = this.readS32()
const nameRef = this.readS32()
const valueRef = this.readS32()
if (nsRef > 0) {
attr.namespaceURI = this.strings[nsRef]
}
attr.nodeName = attr.name = this.strings[nameRef]
if (valueRef > 0) {
// some apk have versionName with special characters
if (attr.name === 'versionName') {
// only keep printable characters
// https://www.ascii-code.com/characters/printable-characters
this.strings[valueRef] = this.strings[valueRef].replace(/[^\x21-\x7E]/g, '')
}
attr.value = this.strings[valueRef]
}
attr.typedValue = this.readTypedValue()
this.debug && console.groupEnd()
return attr
}
readXmlElementEnd (/* header */) {
this.debug && console.group('readXmlCData')
/* const line = */ this.readU32()
/* const commentRef = */ this.readU32()
/* const nsRef = */ this.readS32()
/* const nameRef = */ this.readS32()
this.stack.pop()
this.parent = this.stack[this.stack.length - 1]
this.debug && console.groupEnd()
return null
}
readXmlCData (/* header */) {
this.debug && console.group('readXmlCData')
const cdata = {
namespaceURI: null,
nodeType: NodeType.CDATA_SECTION_NODE,
nodeName: '#cdata',
data: null,
typedValue: null
}
/* const line = */ this.readU32()
/* const commentRef = */ this.readU32()
const dataRef = this.readS32()
if (dataRef > 0) {
cdata.data = this.strings[dataRef]
}
cdata.typedValue = this.readTypedValue()
this.parent.childNodes.push(cdata)
this.debug && console.groupEnd()
return cdata
}
readNull (header) {
this.debug && console.group('readNull')
this.cursor += header.chunkSize - header.headerSize
this.debug && console.groupEnd()
return null
}
parse () {
this.debug && console.group('BinaryXmlParser.parse')
const xmlHeader = this.readChunkHeader()
if (xmlHeader.chunkType !== ChunkType.XML) {
throw new Error('Invalid XML header')
}
while (this.cursor < this.buffer.length) {
this.debug && console.group('chunk')
const start = this.cursor
const header = this.readChunkHeader()
switch (header.chunkType) {
case ChunkType.STRING_POOL:
this.readStringPool(header)
break
case ChunkType.XML_RESOURCE_MAP:
this.readResourceMap(header)
break
case ChunkType.XML_START_NAMESPACE:
this.readXmlNamespaceStart(header)
break
case ChunkType.XML_END_NAMESPACE:
this.readXmlNamespaceEnd(header)
break
case ChunkType.XML_START_ELEMENT:
this.readXmlElementStart(header)
break
case ChunkType.XML_END_ELEMENT:
this.readXmlElementEnd(header)
break
case ChunkType.XML_CDATA:
this.readXmlCData(header)
break
case ChunkType.NULL:
this.readNull(header)
break
default:
throw new Error(`Unsupported chunk type '${header.chunkType}'`)
}
// Ensure we consume the whole chunk
const end = start + header.chunkSize
if (this.cursor !== end) {
const diff = end - this.cursor
const type = header.chunkType.toString(16)
console.debug(`Cursor is off by ${diff} bytes at ${this.cursor} at supposed \
end of chunk of type 0x${type}. The chunk started at offset ${start} and is \
supposed to end at offset ${end}. Ignoring the rest of the chunk.`)
this.cursor = end
}
this.debug && console.groupEnd()
}
this.debug && console.groupEnd()
return this.document
}
}
module.exports = BinaryXmlParser

View File

@@ -1,216 +0,0 @@
// From https://github.com/openstf/adbkit-apkreader
const BinaryXmlParser = require('./binary')
const INTENT_MAIN = 'android.intent.action.MAIN'
const CATEGORY_LAUNCHER = 'android.intent.category.LAUNCHER'
class ManifestParser {
constructor (buffer, options = {}) {
this.buffer = buffer
this.xmlParser = new BinaryXmlParser(this.buffer, options)
}
collapseAttributes (element) {
const collapsed = Object.create(null)
for (let attr of Array.from(element.attributes)) {
collapsed[attr.name] = attr.typedValue.value
}
return collapsed
}
parseIntents (element, target) {
target.intentFilters = []
target.metaData = []
return element.childNodes.forEach(element => {
switch (element.nodeName) {
case 'intent-filter': {
const intentFilter = this.collapseAttributes(element)
intentFilter.actions = []
intentFilter.categories = []
intentFilter.data = []
element.childNodes.forEach(element => {
switch (element.nodeName) {
case 'action':
intentFilter.actions.push(this.collapseAttributes(element))
break
case 'category':
intentFilter.categories.push(this.collapseAttributes(element))
break
case 'data':
intentFilter.data.push(this.collapseAttributes(element))
break
}
})
target.intentFilters.push(intentFilter)
break
}
case 'meta-data':
target.metaData.push(this.collapseAttributes(element))
break
}
})
}
parseApplication (element) {
const app = this.collapseAttributes(element)
app.activities = []
app.activityAliases = []
app.launcherActivities = []
app.services = []
app.receivers = []
app.providers = []
app.usesLibraries = []
app.metaData = []
element.childNodes.forEach(element => {
switch (element.nodeName) {
case 'activity': {
const activity = this.collapseAttributes(element)
this.parseIntents(element, activity)
app.activities.push(activity)
if (this.isLauncherActivity(activity)) {
app.launcherActivities.push(activity)
}
break
}
case 'activity-alias': {
const activityAlias = this.collapseAttributes(element)
this.parseIntents(element, activityAlias)
app.activityAliases.push(activityAlias)
if (this.isLauncherActivity(activityAlias)) {
app.launcherActivities.push(activityAlias)
}
break
}
case 'service': {
const service = this.collapseAttributes(element)
this.parseIntents(element, service)
app.services.push(service)
break
}
case 'receiver': {
const receiver = this.collapseAttributes(element)
this.parseIntents(element, receiver)
app.receivers.push(receiver)
break
}
case 'provider': {
const provider = this.collapseAttributes(element)
provider.grantUriPermissions = []
provider.metaData = []
provider.pathPermissions = []
element.childNodes.forEach(element => {
switch (element.nodeName) {
case 'grant-uri-permission':
provider.grantUriPermissions.push(this.collapseAttributes(element))
break
case 'meta-data':
provider.metaData.push(this.collapseAttributes(element))
break
case 'path-permission':
provider.pathPermissions.push(this.collapseAttributes(element))
break
}
})
app.providers.push(provider)
break
}
case 'uses-library':
app.usesLibraries.push(this.collapseAttributes(element))
break
case 'meta-data':
app.metaData.push(this.collapseAttributes(element))
break
}
})
return app
}
isLauncherActivity (activity) {
return activity.intentFilters.some(function (filter) {
const hasMain = filter.actions.some(action => action.name === INTENT_MAIN)
if (!hasMain) {
return false
}
return filter.categories.some(category => category.name === CATEGORY_LAUNCHER)
})
}
parse () {
const document = this.xmlParser.parse()
const manifest = this.collapseAttributes(document)
manifest.usesPermissions = []
manifest.usesPermissionsSDK23 = []
manifest.permissions = []
manifest.permissionTrees = []
manifest.permissionGroups = []
manifest.instrumentation = null
manifest.usesSdk = null
manifest.usesConfiguration = null
manifest.usesFeatures = []
manifest.supportsScreens = null
manifest.compatibleScreens = []
manifest.supportsGlTextures = []
manifest.application = Object.create(null)
document.childNodes.forEach(element => {
switch (element.nodeName) {
case 'uses-permission':
manifest.usesPermissions.push(this.collapseAttributes(element))
break
case 'uses-permission-sdk-23':
manifest.usesPermissionsSDK23.push(this.collapseAttributes(element))
break
case 'permission':
manifest.permissions.push(this.collapseAttributes(element))
break
case 'permission-tree':
manifest.permissionTrees.push(this.collapseAttributes(element))
break
case 'permission-group':
manifest.permissionGroups.push(this.collapseAttributes(element))
break
case 'instrumentation':
manifest.instrumentation = this.collapseAttributes(element)
break
case 'uses-sdk':
manifest.usesSdk = this.collapseAttributes(element)
break
case 'uses-configuration':
manifest.usesConfiguration = this.collapseAttributes(element)
break
case 'uses-feature':
manifest.usesFeatures.push(this.collapseAttributes(element))
break
case 'supports-screens':
manifest.supportsScreens = this.collapseAttributes(element)
break
case 'compatible-screens':
element.childNodes.forEach(screen => {
return manifest.compatibleScreens.push(this.collapseAttributes(screen))
})
break
case 'supports-gl-texture':
manifest.supportsGlTextures.push(this.collapseAttributes(element))
break
case 'application':
manifest.application = this.parseApplication(element)
break
}
})
return manifest
}
}
module.exports = ManifestParser

View File

@@ -1,66 +0,0 @@
const Unzip = require('isomorphic-unzip');
const { isBrowser, decodeNullUnicode } = require('./utils');
import { enumZipEntries, readEntire } from '../../bundle';
class Zip {
constructor(file) {
if (isBrowser()) {
if (!(file instanceof window.Blob || typeof file.size !== 'undefined')) {
throw new Error(
'Param error: [file] must be an instance of Blob or File in browser.',
);
}
this.file = file;
} else {
if (typeof file !== 'string') {
throw new Error('Param error: [file] must be file path in Node.');
}
this.file = require('path').resolve(file);
}
this.unzip = new Unzip(this.file);
}
/**
* get entries by regexps, the return format is: { <filename>: <Buffer|Blob> }
* @param {Array} regexps // regexps for matching files
* @param {String} type // return type, can be buffer or blob, default buffer
*/
getEntries(regexps, type = 'buffer') {
regexps = regexps.map((regex) => decodeNullUnicode(regex));
return new Promise((resolve, reject) => {
this.unzip.getBuffer(regexps, { type }, (err, buffers) => {
err ? reject(err) : resolve(buffers);
});
});
}
/**
* get entry by regex, return an instance of Buffer or Blob
* @param {Regex} regex // regex for matching file
* @param {String} type // return type, can be buffer or blob, default buffer
*/
getEntry(regex, type = 'buffer') {
regex = decodeNullUnicode(regex);
return new Promise((resolve, reject) => {
this.unzip.getBuffer([regex], { type }, (err, buffers) => {
// console.log(buffers);
err ? reject(err) : resolve(buffers[regex]);
});
});
}
async getEntryFromHarmonyApp(regex) {
try {
let originSource;
await enumZipEntries(this.file, (entry, zipFile) => {
if (regex.test(entry.fileName)) {
return readEntire(entry, zipFile).then((v) => (originSource = v));
}
});
return originSource;
} catch (error) {
console.error('Error in getEntryFromHarmonyApp:', error);
}
}
}
module.exports = Zip;

View File

@@ -1,29 +0,0 @@
import fs from 'node:fs';
import { t } from './i18n';
const lockFiles = [
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml',
'bun.lockb',
'bun.lock',
];
const existingLockFiles: string[] = [];
export function checkLockFiles() {
for (const file of lockFiles) {
if (fs.existsSync(file)) {
existingLockFiles.push(file);
}
}
if (existingLockFiles.length === 1) {
return;
}
console.warn(t('lockBestPractice'));
if (existingLockFiles.length === 0) {
throw new Error(t('lockNotFound'));
}
throw new Error(
t('multipleLocksFound', { lockFiles: existingLockFiles.join(', ') }),
);
}

View File

@@ -1,28 +0,0 @@
import { plugins } from './plugin-config';
interface BundleParams {
sentry: boolean;
sourcemap: boolean;
[key: string]: any;
}
export async function checkPlugins(): Promise<BundleParams> {
const params: BundleParams = {
sentry: false,
sourcemap: false,
};
for (const plugin of plugins) {
try {
const isEnabled = await plugin.detect();
if (isEnabled && plugin.bundleParams) {
Object.assign(params, plugin.bundleParams);
console.log(`detected ${plugin.name} plugin`);
}
} catch (err) {
console.warn(`error while detecting ${plugin.name} plugin:`, err);
}
}
return params;
}

View File

@@ -1,15 +0,0 @@
import path from 'node:path';
const scriptName = path.basename(process.argv[1]) as 'cresc' | 'pushy';
export const IS_CRESC = scriptName === 'cresc';
export const credentialFile = IS_CRESC ? '.cresc.token' : '.update';
export const updateJson = IS_CRESC ? 'cresc.config.json' : 'update.json';
export const tempDir = IS_CRESC ? '.cresc.temp' : '.pushy';
export const pricingPageUrl = IS_CRESC
? 'https://cresc.dev/pricing'
: 'https://pushy.reactnative.cn/pricing.html';
export const defaultEndpoint = IS_CRESC
? 'https://api.cresc.dev'
: 'https://update.reactnative.cn/api';

View File

@@ -1,26 +0,0 @@
const currentPackage = require(`${process.cwd()}/package.json`);
const depKeys = Object.keys(currentPackage.dependencies);
const devDepKeys = Object.keys(currentPackage.devDependencies);
const dedupedDeps = [...new Set([...depKeys, ...devDepKeys])];
const _depVersions: Record<string, string> = {};
for (const dep of dedupedDeps) {
try {
const packageJsonPath = require.resolve(`${dep}/package.json`, {
paths: [process.cwd()],
});
const version = require(packageJsonPath).version;
_depVersions[dep] = version;
} catch (e) {}
}
export const depVersions = Object.keys(_depVersions)
.sort() // Sort the keys alphabetically
.reduce((obj, key) => {
obj[key] = _depVersions[key]; // Rebuild the object with sorted keys
return obj;
}, {} as Record<string, string>);
// console.log({ depVersions });

View File

@@ -1,50 +0,0 @@
import git from 'isomorphic-git';
import fs from 'node:fs';
import path from 'node:path';
export interface CommitInfo {
hash: string;
message: string;
author: string;
timestamp: string;
origin: string;
}
function findGitRoot(dir = process.cwd()) {
const gitRoot = fs.readdirSync(dir).find((dir) => dir === '.git');
if (gitRoot) {
// console.log({ gitRoot });
return path.join(dir, gitRoot);
}
const parentDir = path.dirname(dir);
if (parentDir === dir) {
return null;
}
return findGitRoot(parentDir);
}
const gitRoot = findGitRoot();
export async function getCommitInfo(): Promise<CommitInfo | undefined> {
if (!gitRoot) {
return;
}
try {
const remotes = await git.listRemotes({ fs, gitdir: gitRoot });
const origin =
remotes.find((remote) => remote.remote === 'origin') || remotes[0];
const { commit, oid } = (
await git.log({ fs, gitdir: gitRoot, depth: 1 })
)[0];
return {
hash: oid,
message: commit.message,
author: commit.author.name || commit.committer.name,
timestamp: String(commit.committer.timestamp),
origin: origin?.url,
};
} catch (error) {
console.error(error);
return;
}
}

View File

@@ -1,34 +0,0 @@
import i18next from 'i18next';
import en from '../locales/en';
import zh from '../locales/zh';
import { IS_CRESC } from './constants';
i18next.init({
lng: IS_CRESC ? 'en' : 'zh',
// debug: process.env.NODE_ENV !== 'production',
// debug: true,
resources: {
en: {
translation: en,
},
zh: {
translation: zh,
},
},
});
declare module 'i18next' {
// Extend CustomTypeOptions
interface CustomTypeOptions {
// custom namespace type, if you changed it
defaultNS: 'en';
// custom resources type
resources: {
en: typeof en;
zh: typeof zh;
};
// other
}
}
export const t = i18next.t;

153
src/utils/index.js Normal file
View File

@@ -0,0 +1,153 @@
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import pkg from '../../package.json';
import AppInfoParser from 'app-info-parser';
import read from 'read';
export function question(query, password) {
if (NO_INTERACTIVE) {
return Promise.resolve('');
}
return new Promise((resolve, reject) =>
read(
{
prompt: query,
silent: password,
replace: password ? '*' : undefined,
},
(err, result) => (err ? reject(err) : resolve(result)),
),
);
}
export function translateOptions(options) {
const ret = {};
for (let key in options) {
const v = options[key];
if (typeof v === 'string') {
ret[key] = v.replace(/\$\{(\w+)\}/g, function (v, n) {
return options[n] || process.env[n] || v;
});
} else {
ret[key] = v;
}
}
return ret;
}
export function getRNVersion() {
const version = JSON.parse(
fs.readFileSync(
require.resolve('react-native/package.json', {
paths: [process.cwd()],
}),
),
).version;
// We only care about major and minor version.
const match = /^(\d+)\.(\d+)\./.exec(version);
return {
version,
major: match[1] | 0,
minor: match[2] | 0,
};
}
export async function getApkInfo(fn) {
const appInfoParser = new AppInfoParser(fn);
const bundleFile = await appInfoParser.parser.getEntry(
/assets\/index.android.bundle/,
);
if (!bundleFile) {
throw new Error(
'找不到bundle文件。请确保此apk为release版本且bundle文件名为默认的index.android.bundle',
);
}
const updateJsonFile = await appInfoParser.parser.getEntry(
/res\/raw\/update.json/,
);
let appCredential = {};
if (updateJsonFile) {
appCredential = JSON.parse(updateJsonFile.toString()).android;
}
const { versionName, application } = await appInfoParser.parse();
let buildTime = 0;
if (Array.isArray(application.metaData)) {
for (const meta of application.metaData) {
if (meta.name === 'pushy_build_time') {
buildTime = meta.value[0];
}
}
}
if (buildTime == 0) {
throw new Error(
'无法获取此包的编译时间戳。请更新react-native-update到最新版本后重新打包上传。',
);
}
return { versionName, buildTime, ...appCredential };
}
export async function getIpaInfo(fn) {
const appInfoParser = new AppInfoParser(fn);
const bundleFile = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/main.jsbundle/,
);
if (!bundleFile) {
throw new Error(
'找不到bundle文件。请确保此ipa为release版本且bundle文件名为默认的main.jsbundle',
);
}
const updateJsonFile = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/assets\/update.json/,
);
let appCredential = {};
if (updateJsonFile) {
appCredential = JSON.parse(updateJsonFile.toString()).ios;
}
const { CFBundleShortVersionString: versionName } =
await appInfoParser.parse();
let buildTimeTxtBuffer = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/pushy_build_time.txt/,
);
if (!buildTimeTxtBuffer) {
// Not in root bundle when use `use_frameworks`
buildTimeTxtBuffer = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/frameworks\/react_native_update.framework\/pushy_build_time.txt/,
);
}
if (!buildTimeTxtBuffer) {
throw new Error(
'无法获取此包的编译时间戳。请更新react-native-update到最新版本后重新打包上传。',
);
}
const buildTime = buildTimeTxtBuffer.toString().replace('\n', '');
return { versionName, buildTime, ...appCredential };
}
const localDir = path.resolve(os.homedir(), '.pushy');
fs.ensureDirSync(localDir);
export function saveToLocal(originPath, destName) {
// TODO
// const destPath = path.join(localDir, destName);
// fs.ensureDirSync(path.dirname(destPath));
// fs.copyFileSync(originPath, destPath);
}
export function printVersionCommand() {
console.log('react-native-update-cli: ' + pkg.version);
try {
const PACKAGE_JSON_PATH = require.resolve(
'react-native-update/package.json',
{
paths: [process.cwd()],
},
);
console.log('react-native-update: ' + require(PACKAGE_JSON_PATH).version);
} catch (e) {
console.log('react-native-update: 无法获取版本号,请在项目目录中运行命令');
}
}
export const pricingPageUrl = 'https://pushy.reactnative.cn/pricing.html';

View File

@@ -1,223 +0,0 @@
import fs from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
import pkg from '../../package.json';
import AppInfoParser from './app-info-parser';
import semverSatisfies from 'semver/functions/satisfies';
import chalk from 'chalk';
import latestVersion from '@badisi/latest-version';
import { checkPlugins } from './check-plugin';
import { read } from 'read';
import { IS_CRESC, tempDir } from './constants';
import { depVersions } from './dep-versions';
import { t } from './i18n';
export async function question(query: string, password?: boolean) {
if (NO_INTERACTIVE) {
return '';
}
return read({
prompt: query,
silent: password,
replace: password ? '*' : undefined,
});
}
export function translateOptions(options: Record<string, string>) {
const ret: Record<string, string> = {};
for (const key in options) {
const v = options[key];
if (typeof v === 'string') {
ret[key] = v.replace(
/\$\{(\w+)\}/g,
(v, n) => options[n] || process.env[n] || v,
);
} else {
ret[key] = v;
}
}
return ret;
}
export async function getApkInfo(fn: string) {
const appInfoParser = new AppInfoParser(fn);
const bundleFile = await appInfoParser.parser.getEntry(
/assets\/index.android.bundle/,
);
if (!bundleFile) {
throw new Error(
t('bundleNotFound', {
packageType: 'apk',
entryFile: 'index.android.bundle',
}),
);
}
const updateJsonFile = await appInfoParser.parser.getEntry(
/res\/raw\/update.json/,
);
let appCredential = {};
if (updateJsonFile) {
appCredential = JSON.parse(updateJsonFile.toString()).android;
}
const { versionName, application } = await appInfoParser.parse();
let buildTime = 0;
if (Array.isArray(application.metaData)) {
for (const meta of application.metaData) {
if (meta.name === 'pushy_build_time') {
buildTime = meta.value[0];
}
}
}
if (buildTime == 0) {
throw new Error(t('buildTimeNotFound'));
}
return { versionName, buildTime, ...appCredential };
}
export async function getAppInfo(fn: string) {
const appInfoParser = new AppInfoParser(fn);
const bundleFile = await appInfoParser.parser.getEntryFromHarmonyApp(
/rawfile\/bundle.harmony.js/,
);
if (!bundleFile) {
throw new Error(
t('bundleNotFound', {
packageType: 'app',
entryFile: 'bundle.harmony.js',
}),
);
}
const updateJsonFile = await appInfoParser.parser.getEntryFromHarmonyApp(
/rawfile\/update.json/,
);
let appCredential = {};
if (updateJsonFile) {
appCredential = JSON.parse(updateJsonFile.toString()).harmony;
}
const metaJsonFile = await appInfoParser.parser.getEntryFromHarmonyApp(
/rawfile\/meta.json/,
);
let metaData = {};
if (metaJsonFile) {
metaData = JSON.parse(metaJsonFile.toString());
}
const { versionName, pushy_build_time } = metaData;
let buildTime = 0;
if (pushy_build_time) {
buildTime = pushy_build_time;
}
if (buildTime == 0) {
throw new Error(t('buildTimeNotFound'));
}
return { versionName, buildTime, ...appCredential };
}
export async function getIpaInfo(fn: string) {
const appInfoParser = new AppInfoParser(fn);
const bundleFile = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/main.jsbundle/,
);
if (!bundleFile) {
throw new Error(
t('bundleNotFound', {
packageType: 'ipa',
entryFile: 'main.jsbundle',
}),
);
}
const updateJsonFile = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/assets\/update.json/,
);
let appCredential = {};
if (updateJsonFile) {
appCredential = JSON.parse(updateJsonFile.toString()).ios;
}
const { CFBundleShortVersionString: versionName } =
await appInfoParser.parse();
let buildTimeTxtBuffer = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/pushy_build_time.txt/,
);
if (!buildTimeTxtBuffer) {
// Not in root bundle when use `use_frameworks`
buildTimeTxtBuffer = await appInfoParser.parser.getEntry(
/payload\/.+?\.app\/frameworks\/react_native_update.framework\/pushy_build_time.txt/,
);
}
if (!buildTimeTxtBuffer) {
throw new Error(t('buildTimeNotFound'));
}
const buildTime = buildTimeTxtBuffer.toString().replace('\n', '');
return { versionName, buildTime, ...appCredential };
}
const localDir = path.resolve(os.homedir(), tempDir);
fs.ensureDirSync(localDir);
export function saveToLocal(originPath: string, destName: string) {
// TODO
// const destPath = path.join(localDir, destName);
// fs.ensureDirSync(path.dirname(destPath));
// fs.copyFileSync(originPath, destPath);
}
async function getLatestVersion(pkgNames: string[]) {
return latestVersion(pkgNames, {
// useCache: true,
requestOptions: {
timeout: 2000,
},
})
.then((pkgs) => pkgs.map((pkg) => pkg.latest))
.catch(() => []);
}
export async function printVersionCommand() {
let [latestRnuCliVersion, latestRnuVersion] = await getLatestVersion([
'react-native-update-cli',
'react-native-update',
]);
latestRnuCliVersion = latestRnuCliVersion
? ` ${t('latestVersionTag', {
version: chalk.green(latestRnuCliVersion),
})}`
: '';
console.log(
`react-native-update-cli: ${pkg.version}${latestRnuCliVersion}`,
);
let rnuVersion = '';
rnuVersion = depVersions['react-native-update'];
latestRnuVersion = latestRnuVersion
? ` ${t('latestVersionTag', { version: chalk.green(latestRnuVersion) })}`
: '';
console.log(`react-native-update: ${rnuVersion}${latestRnuVersion}`);
if (rnuVersion) {
if (IS_CRESC) {
if (semverSatisfies(rnuVersion, '<10.27.0')) {
console.error(
'Unsupported version, please update to the latest version: npm i react-native-update@latest',
);
process.exit(1);
}
} else {
if (semverSatisfies(rnuVersion, '<8.5.2')) {
console.warn(
`当前版本已不再支持,请至少升级到 v8 的最新小版本后重新打包(代码无需改动): npm i react-native-update@8 .
如有使用安装 apk 的功能,请注意添加所需权限 https://pushy.reactnative.cn/docs/api#async-function-downloadandinstallapkurl`,
);
} else if (semverSatisfies(rnuVersion, '9.0.0 - 9.2.1')) {
console.warn(
`当前版本已不再支持,请至少升级到 v9 的最新小版本后重新打包(代码无需改动,可直接热更): npm i react-native-update@9 .
如有使用安装 apk 的功能,请注意添加所需权限 https://pushy.reactnative.cn/docs/api#async-function-downloadandinstallapkurl`,
);
} else if (semverSatisfies(rnuVersion, '10.0.0 - 10.17.0')) {
console.warn(
'当前版本已不再支持,请升级到 v10 的最新小版本(代码无需改动,可直接热更): npm i react-native-update@10',
);
}
}
} else {
console.log(t('rnuVersionNotFound'));
}
}
export { checkPlugins };

View File

@@ -1,32 +0,0 @@
import fs from 'fs-extra';
interface PluginConfig {
name: string;
bundleParams?: {
[key: string]: any;
};
detect: () => Promise<boolean>;
}
export const plugins: PluginConfig[] = [
{
name: 'sentry',
bundleParams: {
sentry: true,
sourcemap: true,
},
detect: async () => {
try {
await fs.access('ios/sentry.properties');
return true;
} catch {
try {
await fs.access('android/sentry.properties');
return true;
} catch {
return false;
}
}
}
}
];

166
src/versions.js Normal file
View File

@@ -0,0 +1,166 @@
import { get, post, put, uploadFile } from './api';
import { question, saveToLocal } from './utils';
import { checkPlatform, getSelectedApp } from './app';
import { choosePackage } from './package';
async function showVersion(appId, offset) {
const { data, count } = await get(`/app/${appId}/version/list`);
console.log(`Offset ${offset}`);
for (const version of data) {
let packageInfo = version.packages
.slice(0, 3)
.map((v) => v.name)
.join(', ');
const count = version.packages.length;
if (count > 3) {
packageInfo += `...and ${count - 3} more`;
}
if (count === 0) {
packageInfo = `(no package)`;
} else {
packageInfo = `[${packageInfo}]`;
}
console.log(
`${version.id}) ${version.hash.slice(0, 8)} ${
version.name
} ${packageInfo}`,
);
}
return data;
}
async function listVersions(appId) {
let offset = 0;
while (true) {
await showVersion(appId, offset);
const cmd = await question('page Up/page Down/Begin/Quit(U/D/B/Q)');
switch (cmd.toLowerCase()) {
case 'u':
offset = Math.max(0, offset - 10);
break;
case 'd':
offset += 10;
break;
case 'b':
offset = 0;
break;
case 'q':
return;
}
}
}
async function chooseVersion(appId) {
let offset = 0;
while (true) {
const data = await showVersion(appId, offset);
const cmd = await question(
'Enter versionId or page Up/page Down/Begin(U/D/B)',
);
switch (cmd.toLowerCase()) {
case 'U':
offset = Math.max(0, offset - 10);
break;
case 'D':
offset += 10;
break;
case 'B':
offset = 0;
break;
default: {
const v = data.find((v) => v.id === (cmd | 0));
if (v) {
return v;
}
}
}
}
}
export const commands = {
publish: async function ({ args, options }) {
const fn = args[0];
const { name, description, metaInfo } = options;
if (!fn || !fn.endsWith('.ppk')) {
throw new Error(
'使用方法: pushy publish ppk后缀文件 --platform ios|android',
);
}
const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')),
);
const { appId } = await getSelectedApp(platform);
const { hash } = await uploadFile(fn);
const { id } = await post(`/app/${appId}/version/create`, {
name: name || (await question('输入版本名称: ')) || '(未命名)',
hash,
description: description || (await question('输入版本描述:')),
metaInfo: metaInfo || (await question('输入自定义的 meta info:')),
});
// TODO local diff
saveToLocal(fn, `${appId}/ppk/${id}.ppk`);
console.log(`已成功上传新热更包id: ${id}`);
const v = await question('是否现在将此热更应用到原生包上?(Y/N)');
if (v.toLowerCase() === 'y') {
await this.update({ args: [], options: { versionId: id, platform } });
}
},
versions: async function ({ options }) {
const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')),
);
const { appId } = await getSelectedApp(platform);
await listVersions(appId);
},
update: async function ({ args, options }) {
const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')),
);
const { appId } = await getSelectedApp(platform);
const versionId = options.versionId || (await chooseVersion(appId)).id;
let pkgId;
let pkgVersion = options.packageVersion;
if (pkgVersion) {
pkgVersion = pkgVersion.trim();
const { data } = await get(`/app/${appId}/package/list?limit=1000`);
const pkg = data.find((d) => d.name === pkgVersion);
if (pkg) {
pkgId = pkg.id;
} else {
throw new Error(`未查询到匹配原生版本:${pkgVersion}`);
}
}
if (!pkgId) {
pkgId = options.packageId || (await choosePackage(appId)).id;
}
if (!pkgId) {
throw new Error('请提供 packageId 或 packageVersion 参数');
}
await put(`/app/${appId}/package/${pkgId}`, {
versionId,
});
console.log('操作成功');
},
updateVersionInfo: async function ({ args, options }) {
const platform = checkPlatform(
options.platform || (await question('平台(ios/android):')),
);
const { appId } = await getSelectedApp(platform);
const versionId = options.versionId || (await chooseVersion(appId)).id;
const updateParams = {};
options.name && (updateParams.name = options.name);
options.description && (updateParams.description = options.description);
options.metaInfo && (updateParams.metaInfo = options.metaInfo);
await put(`/app/${appId}/version/${versionId}`, updateParams);
console.log('操作成功');
},
};

View File

@@ -1,344 +0,0 @@
import { get, post, put, uploadFile } from './api';
import { question, saveToLocal } from './utils';
import { checkPlatform, getSelectedApp } from './app';
import { choosePackage } from './package';
import { compare } from 'compare-versions';
import { depVersions } from './utils/dep-versions';
import { getCommitInfo } from './utils/git';
import type { Platform } from 'types';
interface Package {
id: string;
name: string;
}
interface Version {
id: string;
hash: string;
name: string;
packages?: Package[];
}
interface CommandOptions {
name?: string;
description?: string;
metaInfo?: string;
platform?: Platform;
versionId?: string;
packageId?: string;
packageVersion?: string;
minPackageVersion?: string;
maxPackageVersion?: string;
rollout?: string;
}
async function showVersion(appId: string, offset: number) {
const { data, count } = await get(`/app/${appId}/version/list`);
console.log(`Offset ${offset}`);
for (const version of data) {
const pkgCount = version.packages?.length || 0;
let packageInfo = '';
if (pkgCount === 0) {
packageInfo = 'no package';
} else {
packageInfo = version.packages
?.slice(0, 3)
.map((pkg: Package) => pkg.name)
.join(', ');
if (pkgCount > 3) {
packageInfo += `...and ${pkgCount - 3} more`;
} else {
packageInfo = `[${packageInfo}]`;
}
}
console.log(
`${version.id}) ${version.hash.slice(0, 8)} ${
version.name
} ${packageInfo}`,
);
}
return data;
}
async function listVersions(appId: string) {
let offset = 0;
while (true) {
await showVersion(appId, offset);
const cmd = await question('page Up/page Down/Begin/Quit(U/D/B/Q)');
switch (cmd.toLowerCase()) {
case 'u':
offset = Math.max(0, offset - 10);
break;
case 'd':
offset += 10;
break;
case 'b':
offset = 0;
break;
case 'q':
return;
}
}
}
async function chooseVersion(appId: string) {
let offset = 0;
while (true) {
const data = await showVersion(appId, offset);
const cmd = await question(
'Enter versionId or page Up/page Down/Begin(U/D/B)',
);
switch (cmd.toUpperCase()) {
case 'U':
offset = Math.max(0, offset - 10);
break;
case 'D':
offset += 10;
break;
case 'B':
offset = 0;
break;
default: {
const versionId = Number.parseInt(cmd, 10);
const v = data.find(
(version: Version) => version.id === String(versionId),
);
if (v) {
return v;
}
}
}
}
}
export const commands = {
publish: async function ({
args,
options,
}: {
args: string[];
options: CommandOptions;
}) {
const fn = args[0];
const { name, description, metaInfo } = options;
if (!fn || !fn.endsWith('.ppk')) {
throw new Error(
'使用方法: pushy publish ppk后缀文件 --platform ios|android|harmony',
);
}
const platform = checkPlatform(
options.platform ||
((await question('平台(ios/android/harmony):')) as Platform),
);
const { appId } = await getSelectedApp(platform);
const { hash } = await uploadFile(fn);
const versionName =
name || (await question('输入版本名称: ')) || '(未命名)';
const { id } = await post(`/app/${appId}/version/create`, {
name: versionName,
hash,
description: description || (await question('输入版本描述:')),
metaInfo: metaInfo || (await question('输入自定义的 meta info:')),
deps: depVersions,
commit: await getCommitInfo(),
});
// TODO local diff
saveToLocal(fn, `${appId}/ppk/${id}.ppk`);
console.log(`已成功上传新热更包id: ${id}`);
const v = await question('是否现在将此热更应用到原生包上?(Y/N)');
if (v.toLowerCase() === 'y') {
await this.update({ args: [], options: { versionId: id, platform } });
}
return versionName;
},
versions: async ({ options }: { options: CommandOptions }) => {
const platform = checkPlatform(
options.platform ||
((await question('平台(ios/android/harmony):')) as Platform),
);
const { appId } = await getSelectedApp(platform);
await listVersions(appId);
},
update: async ({
args,
options,
}: {
args: string[];
options: CommandOptions;
}) => {
const platform = checkPlatform(
options.platform ||
((await question('平台(ios/android/harmony):')) as Platform),
);
const { appId } = await getSelectedApp(platform);
let versionId = options.versionId || (await chooseVersion(appId)).id;
if (versionId === 'null') {
versionId = undefined;
}
let pkgId: string | undefined;
let pkgVersion = options.packageVersion;
let minPkgVersion = options.minPackageVersion;
let maxPkgVersion = options.maxPackageVersion;
let rollout: number | undefined = undefined;
if (options.rollout !== undefined) {
try {
rollout = Number.parseInt(options.rollout);
} catch (e) {
throw new Error('rollout 必须是 1-100 的整数');
}
if (rollout < 1 || rollout > 100) {
throw new Error('rollout 必须是 1-100 的整数');
}
}
if (minPkgVersion) {
minPkgVersion = String(minPkgVersion).trim();
const { data } = await get(`/app/${appId}/package/list?limit=1000`);
const pkgs = data.filter((pkg: Package) =>
compare(pkg.name, minPkgVersion, '>='),
);
if (pkgs.length === 0) {
throw new Error(`未查询到 >= ${minPkgVersion} 的原生版本`);
}
if (rollout !== undefined) {
const rolloutConfig: Record<string, number> = {};
for (const pkg of pkgs) {
rolloutConfig[pkg.name] = rollout;
}
await put(`/app/${appId}/version/${versionId}`, {
config: {
rollout: rolloutConfig,
},
});
console.log(
`已在原生版本 ${pkgs
.map((pkg: Package) => pkg.name)
.join(', ')} 上设置灰度发布 ${rollout}% 热更版本 ${versionId}`,
);
}
for (const pkg of pkgs) {
await put(`/app/${appId}/package/${pkg.id}`, {
versionId,
});
console.log(
`已将热更版本 ${versionId} 绑定到原生版本 ${pkg.name} (id: ${pkg.id})`,
);
}
console.log(`操作完成,共已绑定 ${pkgs.length} 个原生版本`);
return;
}
if (maxPkgVersion) {
maxPkgVersion = String(maxPkgVersion).trim();
const { data } = await get(`/app/${appId}/package/list?limit=1000`);
const pkgs = data.filter((pkg: Package) =>
compare(pkg.name, maxPkgVersion, '<='),
);
if (pkgs.length === 0) {
throw new Error(`未查询到 <= ${maxPkgVersion} 的原生版本`);
}
if (rollout !== undefined) {
const rolloutConfig: Record<string, number> = {};
for (const pkg of pkgs) {
rolloutConfig[pkg.name] = rollout;
}
await put(`/app/${appId}/version/${versionId}`, {
config: {
rollout: rolloutConfig,
},
});
console.log(
`已在原生版本 ${pkgs
.map((pkg: Package) => pkg.name)
.join(', ')} 上设置灰度发布 ${rollout}% 热更版本 ${versionId}`,
);
}
for (const pkg of pkgs) {
await put(`/app/${appId}/package/${pkg.id}`, {
versionId,
});
console.log(
`已将热更版本 ${versionId} 绑定到原生版本 ${pkg.name} (id: ${pkg.id})`,
);
}
console.log(`操作完成,共已绑定 ${pkgs.length} 个原生版本`);
return;
}
const { data } = await get(`/app/${appId}/package/list?limit=1000`);
if (pkgVersion) {
pkgVersion = pkgVersion.trim();
const pkg = data.find((pkg: Package) => pkg.name === pkgVersion);
if (pkg) {
pkgId = pkg.id;
} else {
throw new Error(`未查询到匹配原生版本:${pkgVersion}`);
}
}
if (!pkgId) {
pkgId = options.packageId || (await choosePackage(appId)).id;
}
if (!pkgId) {
throw new Error('请提供 packageId 或 packageVersion 参数');
}
if (!pkgVersion) {
const pkg = data.find((pkg: Package) => String(pkg.id) === String(pkgId));
if (pkg) {
pkgVersion = pkg.name;
}
}
if (rollout !== undefined && pkgVersion) {
await put(`/app/${appId}/version/${versionId}`, {
config: {
rollout: {
[pkgVersion]: rollout,
},
},
});
console.log(
`已将在原生版本 ${pkgVersion} (id: ${pkgId}) 上设置灰度发布 ${rollout}% 热更版本 ${versionId} `,
);
}
if (versionId !== undefined) {
await put(`/app/${appId}/package/${pkgId}`, {
versionId,
});
console.log(
`已将热更版本 ${versionId} 绑定到原生版本 ${pkgVersion} (id: ${pkgId})`,
);
}
},
updateVersionInfo: async ({
args,
options,
}: {
args: string[];
options: CommandOptions;
}) => {
const platform = checkPlatform(
options.platform ||
((await question('平台(ios/android/harmony):')) as Platform),
);
const { appId } = await getSelectedApp(platform);
const versionId = options.versionId || (await chooseVersion(appId)).id;
const updateParams: Record<string, string> = {};
if (options.name) updateParams.name = options.name;
if (options.description) updateParams.description = options.description;
if (options.metaInfo) updateParams.metaInfo = options.metaInfo;
await put(`/app/${appId}/version/${versionId}`, updateParams);
console.log('操作成功');
},
};

View File

@@ -1,75 +0,0 @@
{
"compilerOptions": {
/* Basic Options */
"target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
"lib": [
"ESNext"
] /* Specify library files to be included in the compilation. */,
"allowJs": true /* Allow javascript files to be compiled. */,
// "checkJs": true /* Report errors in .js files. */,
"jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
// "outDir": "lib" /* Redirect output structure to the directory. */,
// "rootDir": "src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
// "composite": true, /* Enable project compilation */
// "removeComments": true, /* Do not emit comments to output. */
"noEmit": true /* Do not emit outputs. */,
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
"isolatedModules": true /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */,
/* Strict Type-Checking Options */
"strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
"strictNullChecks": true /* Enable strict null checks. */,
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
"noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true /* Report errors on unused locals. */,
// "noUnusedParameters": true /* Report errors on unused parameters. */,
"noImplicitReturns": false /* Report error when not all code paths in function return a value. */,
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"baseUrl": "./src" /* Base directory to resolve non-absolute module names. */,
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [
// "src",
// "db",
// "config",
// "models"
// ] /* List of root folders whose combined content represents the structure of the project at runtime. */,
"typeRoots": [
"src/types"
] /* List of folders to include type definitions from. */,
// "types": [], /* Type declaration files to be included in compilation. */
"allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
"paths": {
"@/*": ["src/*"]
},
"resolveJsonModule": true,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
"skipLibCheck": true,
"incremental": true
},
"exclude": ["node_modules", "lib"]
}