feat: 脚手架基本ok
This commit is contained in:
72
bin/README.md
Normal file
72
bin/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install -g pake
|
||||
```
|
||||
|
||||
如果安装失败提示没有权限,请使用 `sudo` 运行。
|
||||
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
pake [options] url
|
||||
```
|
||||
打包完成后的应用程序默认为当前工作目录。
|
||||
|
||||
Note: 打包需要用 `Rust` 环境,如果没有 `Rust`,会提示确认安装。如遇安装失败或超时,可[自行安装](https://www.rust-lang.org/tools/install)。
|
||||
|
||||
Note: 目前仅支持 MacOs,后续会支持其他平台。
|
||||
|
||||
|
||||
### url
|
||||
url 为你需要打包的网页链接🔗,必须提供。
|
||||
|
||||
### [options]
|
||||
|
||||
提供了一些特定的选项,打包时可以传递对应参数达到定制化的效果。
|
||||
|
||||
#### [name]
|
||||
应用名称,如输入时未指定,会提示你输入。
|
||||
```shell
|
||||
--name <value>
|
||||
```
|
||||
|
||||
#### [icon]
|
||||
应用icon,支持本地/远程文件,默认为 Pake 自带图标。
|
||||
- MacOS下必须为 `.icns`
|
||||
```shell
|
||||
--icon <path>
|
||||
```
|
||||
|
||||
#### [height]
|
||||
打包后的应用窗口高度,默认 `800px`。
|
||||
```
|
||||
--height <number>
|
||||
```
|
||||
|
||||
|
||||
#### [width]
|
||||
打包后的应用窗口宽度,默认 `1280px`。
|
||||
```
|
||||
--width <number>
|
||||
```
|
||||
|
||||
|
||||
#### [transparent]
|
||||
是否开启沉浸式头部,默认为 `false` 不开启。
|
||||
```
|
||||
--transparent
|
||||
```
|
||||
|
||||
|
||||
#### [resize]
|
||||
是否可以拖动大小,默认为 `true` 可拖动。
|
||||
```
|
||||
--no-resizable
|
||||
```
|
||||
|
||||
#### [fullscreen]
|
||||
打开应用后是否开启全屏,默认为 `false`。
|
||||
```
|
||||
--fullscreen <value>
|
||||
```
|
||||
12
bin/builders/BuilderFactory.ts
Normal file
12
bin/builders/BuilderFactory.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IS_MAC } from '@/utils/platform.js';
|
||||
import { IBuilder } from './base.js';
|
||||
import MacBuilder from './MacBuilder.js';
|
||||
|
||||
export default class BuilderFactory {
|
||||
static create(): IBuilder {
|
||||
if (IS_MAC) {
|
||||
return new MacBuilder();
|
||||
}
|
||||
throw new Error('The current system does not support');
|
||||
}
|
||||
}
|
||||
0
bin/builders/LinuxBuilder.ts
Normal file
0
bin/builders/LinuxBuilder.ts
Normal file
65
bin/builders/MacBuilder.ts
Normal file
65
bin/builders/MacBuilder.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import prompts from 'prompts';
|
||||
import { checkRustInstalled, installRust } from '@/helpers/rust.js';
|
||||
import { PakeAppOptions } from '@/types.js';
|
||||
import { IBuilder } from './base.js';
|
||||
import { shellExec } from '@/utils/shell.js';
|
||||
import tauriConf from '../../src-tauri/tauri.conf.json';
|
||||
import { fileURLToPath } from 'url';
|
||||
import log from 'loglevel';
|
||||
|
||||
export default class MacBuilder implements IBuilder {
|
||||
async prepare() {
|
||||
if (checkRustInstalled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await prompts({
|
||||
type: 'confirm',
|
||||
message: 'Detect you have not installed Rust, install it now?',
|
||||
name: 'value',
|
||||
});
|
||||
|
||||
if (res.value) {
|
||||
// TODO 国内有可能会超时
|
||||
await installRust();
|
||||
} else {
|
||||
log.error('Error: Pake need Rust to package your webapp!!!');
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
async build(url: string, options: PakeAppOptions) {
|
||||
log.debug('PakeAppOptions', options);
|
||||
|
||||
const { width, height, fullscreen, transparent, resizable, identifier, name } = options;
|
||||
|
||||
const tauriConfWindowOptions = {
|
||||
width,
|
||||
height,
|
||||
fullscreen,
|
||||
transparent,
|
||||
resizable,
|
||||
};
|
||||
|
||||
// TODO 下面这块逻辑还可以再拆 目前比较简单
|
||||
Object.assign(tauriConf.tauri.windows[0], { url, ...tauriConfWindowOptions });
|
||||
tauriConf.package.productName = name;
|
||||
tauriConf.tauri.bundle.identifier = identifier;
|
||||
tauriConf.tauri.bundle.icon = [options.icon];
|
||||
|
||||
const npmDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const configJsonPath = path.join(npmDirectory, 'src-tauri/tauri.conf.json');
|
||||
await fs.writeFile(configJsonPath, Buffer.from(JSON.stringify(tauriConf), 'utf-8'));
|
||||
|
||||
const code = await shellExec(`cd ${npmDirectory} && npm run build`);
|
||||
const dmgName = `${name}_${'0.2.0'}_universal.dmg`;
|
||||
const appPath = this.getBuildedAppPath(npmDirectory, dmgName);
|
||||
await fs.copyFile(appPath, path.resolve(`${name}_universal.dmg`));
|
||||
}
|
||||
|
||||
getBuildedAppPath(npmDirectory: string, dmgName: string) {
|
||||
return path.join(npmDirectory, 'src-tauri/target/universal-apple-darwin/release/bundle/dmg', dmgName);
|
||||
}
|
||||
}
|
||||
0
bin/builders/WinBulider.ts
Normal file
0
bin/builders/WinBulider.ts
Normal file
16
bin/builders/base.ts
Normal file
16
bin/builders/base.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { PakeAppOptions } from '@/types.js';
|
||||
|
||||
/**
|
||||
* Builder接口
|
||||
* 不同平台打包过程需要实现 prepare 和 build 方法
|
||||
*/
|
||||
export interface IBuilder {
|
||||
/** 前置检查 */
|
||||
prepare(): Promise<void>;
|
||||
/**
|
||||
* 开始打包
|
||||
* @param url 打包url
|
||||
* @param options 配置参数
|
||||
*/
|
||||
build(url: string, options: PakeAppOptions): Promise<void>;
|
||||
}
|
||||
11
bin/builders/common.ts
Normal file
11
bin/builders/common.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import prompts from 'prompts';
|
||||
|
||||
export async function promptText(message: string, initial?: string) {
|
||||
const response = await prompts({
|
||||
type: 'text',
|
||||
name: 'content',
|
||||
message,
|
||||
initial,
|
||||
});
|
||||
return response.content;
|
||||
}
|
||||
36
bin/cli.ts
Normal file
36
bin/cli.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { program } from 'commander';
|
||||
import { DEFAULT_PAKE_OPTIONS } from './defaults.js';
|
||||
import { PakeCliOptions } from './types.js';
|
||||
import { validateNumberInput, validateUrlInput } from './utils/validate.js';
|
||||
import handleInputOptions from './options/index.js';
|
||||
import BuilderFactory from './builders/BuilderFactory.js';
|
||||
import log from 'loglevel';
|
||||
|
||||
program.version('0.0.1').description('A cli application can package a web page to desktop application');
|
||||
|
||||
program
|
||||
.showHelpAfterError()
|
||||
.argument('<url>', 'the web url you want to package', validateUrlInput)
|
||||
.option('--name <string>', 'application name')
|
||||
.option('--icon <string>', 'application icon', DEFAULT_PAKE_OPTIONS.icon)
|
||||
.option('--height <number>', 'window height', validateNumberInput, DEFAULT_PAKE_OPTIONS.height)
|
||||
.option('--width <number>', 'window width', validateNumberInput, DEFAULT_PAKE_OPTIONS.width)
|
||||
.option('--no-resizable', 'whether the window can be resizable', DEFAULT_PAKE_OPTIONS.resizable)
|
||||
.option('--fullscreen', 'makes the packaged app start in full screen', DEFAULT_PAKE_OPTIONS.fullscreen)
|
||||
.option('--transparent', 'transparent title bar', DEFAULT_PAKE_OPTIONS.transparent)
|
||||
.option('--debug', 'debug', DEFAULT_PAKE_OPTIONS.transparent)
|
||||
.action(async (url: string, options: PakeCliOptions) => {
|
||||
log.setDefaultLevel('info')
|
||||
if (options.debug) {
|
||||
log.setLevel('debug');
|
||||
}
|
||||
|
||||
const builder = BuilderFactory.create();
|
||||
await builder.prepare();
|
||||
|
||||
const appOptions = await handleInputOptions(options, url);
|
||||
|
||||
builder.build(url, appOptions);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
13
bin/defaults.ts
Normal file
13
bin/defaults.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PakeCliOptions } from './types.js';
|
||||
|
||||
export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = {
|
||||
icon: '',
|
||||
height: 800,
|
||||
width: 1280,
|
||||
fullscreen: false,
|
||||
resizable: true,
|
||||
transparent: false,
|
||||
debug: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_NAME = 'Pake';
|
||||
21
bin/helpers/rust.ts
Normal file
21
bin/helpers/rust.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import ora from 'ora';
|
||||
import shelljs from 'shelljs';
|
||||
import { shellExec } from '../utils/shell.js';
|
||||
|
||||
const InstallRustScript = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y";
|
||||
export async function installRust() {
|
||||
const spinner = ora('Downloading Rust').start();
|
||||
try {
|
||||
await shellExec(InstallRustScript);
|
||||
spinner.succeed();
|
||||
} catch (error) {
|
||||
console.error('install rust return code', error.message);
|
||||
spinner.fail();
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function checkRustInstalled() {
|
||||
return shelljs.exec('rustc --version', { silent: true }).code === 0;
|
||||
}
|
||||
8
bin/helpers/tauriConfig.ts
Normal file
8
bin/helpers/tauriConfig.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function getIdentifier(name: string, url: string) {
|
||||
const hash = crypto.createHash('md5');
|
||||
hash.update(url);
|
||||
const postFixHash = hash.digest('hex').substring(0, 6);
|
||||
return `pake-${postFixHash}`;
|
||||
}
|
||||
91
bin/options/icon.ts
Normal file
91
bin/options/icon.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import axios from 'axios';
|
||||
import { fileTypeFromBuffer } from 'file-type';
|
||||
import { PakeAppOptions } from '../types.js';
|
||||
import { dir } from 'tmp-promise';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { fileURLToPath } from 'url';
|
||||
import log from 'loglevel';
|
||||
|
||||
export async function handleIcon(options: PakeAppOptions, url: string) {
|
||||
if (options.icon) {
|
||||
if (options.icon.startsWith('http')) {
|
||||
return downloadIcon(options.icon);
|
||||
} else {
|
||||
return path.resolve(options.icon);
|
||||
}
|
||||
}
|
||||
if (!options.icon) {
|
||||
return inferIcon(options.name, url);
|
||||
}
|
||||
}
|
||||
|
||||
export async function inferIcon(name: string, url: string) {
|
||||
log.info('You have not provided an app icon, use the default icon(can use --icon option to assign an icon)')
|
||||
const npmDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
return path.join(npmDirectory, 'pake-default.icns');
|
||||
}
|
||||
|
||||
// export async function getIconFromPageUrl(url: string) {
|
||||
// const icon = await pageIcon(url);
|
||||
// console.log(icon);
|
||||
// if (icon.ext === '.ico') {
|
||||
// const a = await ICO.parse(icon.data);
|
||||
// icon.data = Buffer.from(a[0].buffer);
|
||||
// }
|
||||
|
||||
// const iconDir = (await dir()).path;
|
||||
// const iconPath = path.join(iconDir, `/icon.icns`);
|
||||
|
||||
// const out = png2icons.createICNS(icon.data, png2icons.BILINEAR, 0);
|
||||
|
||||
// await fs.writeFile(iconPath, out);
|
||||
// return iconPath;
|
||||
// }
|
||||
|
||||
// export async function getIconFromMacosIcons(name: string) {
|
||||
// const data = {
|
||||
// query: name,
|
||||
// filters: 'approved:true',
|
||||
// hitsPerPage: 10,
|
||||
// page: 1,
|
||||
// };
|
||||
// const res = await axios.post('https://p1txh7zfb3-2.algolianet.com/1/indexes/macOSicons/query?x-algolia-agent=Algolia%20for%20JavaScript%20(4.13.1)%3B%20Browser', data, {
|
||||
// headers: {
|
||||
// 'x-algolia-api-key': '0ba04276e457028f3e11e38696eab32c',
|
||||
// 'x-algolia-application-id': 'P1TXH7ZFB3',
|
||||
// },
|
||||
// });
|
||||
// if (!res.data.hits.length) {
|
||||
// return '';
|
||||
// } else {
|
||||
// return downloadIcon(res.data.hits[0].icnsUrl);
|
||||
// }
|
||||
// }
|
||||
|
||||
export async function downloadIcon(iconUrl: string) {
|
||||
let iconResponse;
|
||||
try {
|
||||
iconResponse = await axios.get(iconUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const iconData = await iconResponse.data;
|
||||
if (!iconData) {
|
||||
return null;
|
||||
}
|
||||
const fileDetails = await fileTypeFromBuffer(iconData);
|
||||
if (!fileDetails) {
|
||||
return null;
|
||||
}
|
||||
const { path } = await dir();
|
||||
const iconPath = `${path}/icon.${fileDetails.ext}`;
|
||||
await fs.writeFile(iconPath, iconData);
|
||||
return iconPath;
|
||||
}
|
||||
22
bin/options/index.ts
Normal file
22
bin/options/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { promptText } from '@/builders/common.js';
|
||||
import { getDomain } from '@/utils/url.js';
|
||||
import { getIdentifier } from '../helpers/tauriConfig.js';
|
||||
import { PakeAppOptions, PakeCliOptions } from '../types.js';
|
||||
import { handleIcon } from './icon.js';
|
||||
|
||||
export default async function handleOptions(options: PakeCliOptions, url: string): Promise<PakeAppOptions> {
|
||||
const appOptions: PakeAppOptions = {
|
||||
...options,
|
||||
identifier: '',
|
||||
};
|
||||
|
||||
if (!appOptions.name) {
|
||||
appOptions.name = await promptText('please input your application name', getDomain(url));
|
||||
}
|
||||
|
||||
appOptions.identifier = getIdentifier(appOptions.name, url);
|
||||
|
||||
appOptions.icon = await handleIcon(appOptions, url);
|
||||
|
||||
return appOptions;
|
||||
}
|
||||
29
bin/types.ts
Normal file
29
bin/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface PakeCliOptions {
|
||||
/** 应用名称 */
|
||||
name?: string;
|
||||
|
||||
/** 应用icon */
|
||||
icon: string;
|
||||
|
||||
/** 应用窗口宽度,默认 1280px */
|
||||
width: number;
|
||||
|
||||
/** 应用窗口高度,默认 800px */
|
||||
height: number;
|
||||
|
||||
/** 是否可以拖动,默认true */
|
||||
resizable: boolean;
|
||||
|
||||
/** 是否可以全屏,默认 false */
|
||||
fullscreen: boolean;
|
||||
|
||||
/** 是否开启沉浸式头部,默认为 false 不开启 ƒ*/
|
||||
transparent: boolean;
|
||||
|
||||
/** 调试模式,会输出更多日志 */
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
export interface PakeAppOptions extends PakeCliOptions {
|
||||
identifier: string;
|
||||
}
|
||||
5
bin/utils/platform.ts
Normal file
5
bin/utils/platform.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const IS_MAC = process.platform === 'darwin';
|
||||
|
||||
export const IS_WIN = process.platform === 'win32';
|
||||
|
||||
export const IS_LINUX = process.platform === 'linux';
|
||||
13
bin/utils/shell.ts
Normal file
13
bin/utils/shell.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import shelljs from 'shelljs';
|
||||
|
||||
export function shellExec(command: string) {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
shelljs.exec(command, { async: true, silent: false}, (code) => {
|
||||
if (code === 0) {
|
||||
resolve(0);
|
||||
} else {
|
||||
reject(new Error(`${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
1489
bin/utils/tlds.ts
Normal file
1489
bin/utils/tlds.ts
Normal file
File diff suppressed because it is too large
Load Diff
47
bin/utils/url.ts
Normal file
47
bin/utils/url.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import url from 'url';
|
||||
import isurl from 'is-url';
|
||||
import tlds from './tlds.js';
|
||||
|
||||
export function getDomain(inputUrl: string) {
|
||||
const parsed = url.parse(inputUrl).host;
|
||||
var parts = parsed.split('.');
|
||||
if (parts[0] === 'www' && parts[1] !== 'com') {
|
||||
parts.shift();
|
||||
}
|
||||
var ln = parts.length,
|
||||
i = ln,
|
||||
minLength = parts[parts.length - 1].length,
|
||||
part;
|
||||
|
||||
// iterate backwards
|
||||
while ((part = parts[--i])) {
|
||||
// stop when we find a non-TLD part
|
||||
if (
|
||||
i === 0 || // 'asia.com' (last remaining must be the SLD)
|
||||
i < ln - 2 || // TLDs only span 2 levels
|
||||
part.length < minLength || // 'www.cn.com' (valid TLD as second-level domain)
|
||||
tlds.indexOf(part) < 0 // officialy not a TLD
|
||||
) {
|
||||
return part;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendProtocol(inputUrl: string): string {
|
||||
const parsed = url.parse(inputUrl);
|
||||
if (!parsed.protocol) {
|
||||
const urlWithProtocol = `https://${inputUrl}`;
|
||||
return urlWithProtocol;
|
||||
}
|
||||
return inputUrl;
|
||||
}
|
||||
|
||||
export function normalizeUrl(urlToNormalize: string): string {
|
||||
const urlWithProtocol = appendProtocol(urlToNormalize);
|
||||
|
||||
if (isurl(urlWithProtocol)) {
|
||||
return urlWithProtocol;
|
||||
} else {
|
||||
throw new Error(`Your url "${urlWithProtocol}" is invalid`);
|
||||
}
|
||||
}
|
||||
18
bin/utils/validate.ts
Normal file
18
bin/utils/validate.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as Commander from 'commander';
|
||||
import { normalizeUrl } from './url.js';
|
||||
|
||||
export function validateNumberInput(value: string) {
|
||||
const parsedValue = Number(value);
|
||||
if (isNaN(parsedValue)) {
|
||||
throw new Commander.InvalidArgumentError('Not a number.');
|
||||
}
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
export function validateUrlInput(url: string) {
|
||||
try {
|
||||
return normalizeUrl(url);
|
||||
} catch (error) {
|
||||
throw new Commander.InvalidArgumentError(error.message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user