合并原生壳桥接统一分支

合并 Expo 移动壳与 Tauri 桌面壳工程
保留创作主页、项目入口和现有生产发布脚本
同步 HostBridge 文档、依赖锁文件和原生壳门禁
This commit is contained in:
2026-06-22 22:28:31 +08:00
263 changed files with 72457 additions and 2263 deletions
File diff suppressed because it is too large Load Diff
@@ -54,22 +54,27 @@ if (failures.length > 0) {
console.log('\n[wechat-miniprogram-auth-smoke] 通过');
function checkMiniProgramShell() {
const shellPath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js');
const shellPath = join(repoRoot, 'miniprogram', 'shell', 'webView.js');
const webViewBridgePath = join(repoRoot, 'miniprogram', 'host-bridge', 'webView.js');
const pagePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js');
const shellTemplatePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.wxml');
const authServiceTestPath = join(repoRoot, 'src', 'services', 'authService.test.ts');
ensureNeedles(pagePath, ["require('../../shell/webView')"]);
ensureNeedles(shellPath, [
'/api/auth/wechat/miniprogram-login',
'/api/auth/wechat/bind-phone',
"'x-client-type': MINI_PROGRAM_CLIENT_TYPE",
"'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME",
'auth_provider',
'auth_token',
'auth_binding_status',
'bindingStatus',
'pending_bind_phone',
'wechatPhoneCode',
]);
ensureNeedles(webViewBridgePath, [
'auth_provider',
'auth_token',
'auth_binding_status',
]);
ensureNeedles(shellTemplatePath, ['getPhoneNumber', 'bindgetphonenumber']);
+14
View File
@@ -479,6 +479,7 @@ export async function findAvailablePort({
reservedPorts = new Set(),
maxAttempts = null,
portRange = null,
strict = false,
}) {
const range = normalizePortRange(portRange);
const startPort = normalizePort(preferredPort, 0);
@@ -501,6 +502,18 @@ export async function findAvailablePort({
throw new Error(`端口 ${startPort} 不在允许端口段 ${range.label} 内`);
}
if (strict && startPort !== 0) {
if (reservedPorts.has(startPort)) {
throw new Error(`端口 ${host}:${startPort} 已被当前 dev 启动流程占用,无法严格使用该端口`);
}
if (await isPortAvailable({host, port: startPort})) {
return startPort;
}
throw new Error(`端口 ${host}:${startPort} 不可用,无法严格使用该端口`);
}
const boundedAttempts = range
? Number.isFinite(maxAttempts)
? Math.min(Math.max(0, maxAttempts), range.end - startPort)
@@ -565,6 +578,7 @@ export async function resolveDevStackPorts(config) {
preferredPort: portConfig.preferredPort,
reservedPorts,
portRange: portConfig.portRange,
strict: Boolean(portConfig.strict),
});
reservedPorts.add(resolvedPort);
result[name] = resolvedPort;
+17
View File
@@ -58,6 +58,23 @@ describe('dev stack port utils', () => {
}
});
it('严格端口模式在端口被占用时直接失败而不是漂移', async () => {
const server = await reservePort(0);
const port = server.address().port;
try {
await expect(
findAvailablePort({
host: '127.0.0.1',
preferredPort: port,
strict: true,
}),
).rejects.toThrow('无法严格使用该端口');
} finally {
await new Promise((resolve) => server.close(resolve));
}
});
it('端口查找不会越过 Linux 用户端口段', async () => {
await expect(
findAvailablePort({
+11 -4
View File
@@ -135,6 +135,7 @@ function parseArgs(argv, baseEnv) {
migrationBootstrapSecretMode: 'auto',
watch: false,
interactive: true,
strictWebPort: false,
};
for (let index = 0; index < args.length; index += 1) {
@@ -170,6 +171,9 @@ function parseArgs(argv, baseEnv) {
options.webPort = normalizePort(readValue(), options.webPort);
explicitOptions.add('webPort');
break;
case '--strict-web-port':
options.strictWebPort = true;
break;
case '--admin-web-host':
options.adminWebHost = readValue();
explicitOptions.add('adminWebHost');
@@ -960,13 +964,15 @@ class DevRunner {
async resolvePorts(command) {
const {options} = this;
const portConfig = {};
const portRangeFor = (optionName) =>
this.explicitOptions.has(optionName) ? null : this.state.portRange;
if (command === 'all' || command === 'spacetime') {
if (!options.skipSpacetime && !this.state.spacetimeReused) {
portConfig.spacetime = {
host: options.spacetimeHost,
preferredPort: options.spacetimePort,
portRange: this.state.portRange,
portRange: portRangeFor('spacetimePort'),
};
}
}
@@ -975,7 +981,7 @@ class DevRunner {
portConfig.api = {
host: options.apiHost,
preferredPort: options.apiPort,
portRange: this.state.portRange,
portRange: portRangeFor('apiPort'),
};
}
@@ -983,7 +989,8 @@ class DevRunner {
portConfig.web = {
host: options.webHost,
preferredPort: options.webPort,
portRange: this.state.portRange,
portRange: portRangeFor('webPort'),
strict: options.strictWebPort,
};
}
@@ -991,7 +998,7 @@ class DevRunner {
portConfig.adminWeb = {
host: options.adminWebHost,
preferredPort: options.adminWebPort,
portRange: this.state.portRange,
portRange: portRangeFor('adminWebPort'),
};
}
+30
View File
@@ -139,6 +139,36 @@ describe('dev scheduler argument routing', () => {
}
});
linuxTest('Linux 桌面壳显式指定 web-port 时不被系统级端口段改写', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-'));
try {
const {command, explicitOptions, options} = parseArgs(
['web', '--web-port', '3000', '--strict-web-port'],
{
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
},
);
const runner = new DevRunner(options, {
USER: 'alice',
LOGNAME: 'alice',
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
}, explicitOptions);
await runner.prepareLinuxPortRange(command);
expect(runner.state.portRange.label).toBe('22000-22099');
expect(runner.options.webPort).toBe(3000);
expect(runner.options.apiPort).toBe(22001);
expect(runner.options.spacetimePort).toBe(22002);
expect(runner.options.adminWebPort).toBe(22003);
} finally {
rmSync(tempDir, {recursive: true, force: true});
}
});
test('Windows 仍沿用原有端口解析,不启用 Linux 端口段登记', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
+137 -41
View File
@@ -5,12 +5,17 @@ import vm from 'node:vm';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const repoRoot = process.cwd();
const pageScriptPath = join(
const shellScriptPath = join(
repoRoot,
'miniprogram',
'pages',
'web-view',
'index.js',
'shell',
'webView.js',
);
const webViewHostBridgePath = join(
repoRoot,
'miniprogram',
'host-bridge',
'webView.js',
);
type MiniProgramPage = {
@@ -21,6 +26,9 @@ type MiniProgramPage = {
onShareTimeline: () => Record<string, unknown>;
onShow: () => void;
consumePayResult: () => void;
handleGetPhoneNumber: (event: {
detail?: Record<string, unknown>;
}) => Promise<void>;
};
function createWxMock() {
@@ -43,45 +51,28 @@ function loadWebViewPage(
wxMock: ReturnType<typeof createWxMock>,
configOverrides: Record<string, unknown> = {},
) {
let pageConfig: Record<string, unknown> | null = null;
const source = readFileSync(pageScriptPath, 'utf8');
const sandbox = {
console,
getCurrentPages: () => [],
module: { exports: {} },
Page(config: Record<string, unknown>) {
pageConfig = config;
(globalThis as unknown as { wx: ReturnType<typeof createWxMock> }).wx =
wxMock;
const webViewBridge = loadCommonJsModule(webViewHostBridgePath, {});
const shellModule = loadCommonJsModule(shellScriptPath, {
'../config': {
API_BASE_URL: 'https://www.genarrative.world/',
DEV_API_BASE_URL: 'https://dev.genarrative.world/',
DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/',
MINI_PROGRAM_APP_ID: 'wx-test-app',
MINI_PROGRAM_ENV: 'release',
WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/',
WEB_VIEW_SOURCE_QUERY: {
clientType: 'mini_program',
clientRuntime: 'wechat_mini_program',
},
...configOverrides,
},
setTimeout(callback: () => void) {
callback();
return 1;
},
require(requestPath: string) {
if (requestPath === '../../config') {
return {
API_BASE_URL: 'https://www.genarrative.world/',
DEV_API_BASE_URL: 'https://dev.genarrative.world/',
DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/',
MINI_PROGRAM_APP_ID: 'wx-test-app',
MINI_PROGRAM_ENV: 'release',
WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/',
WEB_VIEW_SOURCE_QUERY: {
clientType: 'mini_program',
clientRuntime: 'wechat_mini_program',
},
...configOverrides,
};
}
throw new Error(`Unexpected require: ${requestPath}`);
},
wx: wxMock,
'../host-bridge/webView': webViewBridge,
}) as {
createWechatWebViewPage: () => Record<string, unknown>;
};
vm.runInNewContext(source, sandbox, { filename: pageScriptPath });
if (!pageConfig) {
throw new Error('web-view page did not call Page()');
}
const pageConfig = shellModule.createWechatWebViewPage();
const page = {
...pageConfig,
@@ -94,9 +85,38 @@ function loadWebViewPage(
return page;
}
function loadCommonJsModule(
filePath: string,
requireMap: Record<string, unknown>,
) {
const source = readFileSync(filePath, 'utf8');
const module = { exports: {} as Record<string, unknown> };
const sandbox = {
console,
getCurrentPages: () => [],
module,
exports: module.exports,
setTimeout(callback: () => void) {
callback();
return 1;
},
require(requestPath: string) {
if (Object.prototype.hasOwnProperty.call(requireMap, requestPath)) {
return requireMap[requestPath];
}
throw new Error(`Unexpected require: ${requestPath}`);
},
wx: globalThis.wx,
};
vm.runInNewContext(source, sandbox, { filename: filePath });
return module.exports;
}
describe('mini-program web-view auth page', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'error').mockImplementation(() => {});
});
test('默认进入时不预登录,直接打开未登录 web-view', async () => {
@@ -264,6 +284,10 @@ describe('mini-program web-view auth page', () => {
data: {
token: 'jwt-pending-wechat',
bindingStatus: 'pending_bind_phone',
user: {
displayName: '陶泥玩家',
publicUserCode: 'SY-12345678',
},
},
});
});
@@ -283,4 +307,76 @@ describe('mini-program web-view auth page', () => {
expect(page.data.loading).toBe(false);
expect(page.data.phoneBindingRequired).toBe(true);
});
test('微信登录失败不向页面透出原生错误', async () => {
const wxMock = createWxMock();
const loginError = { errMsg: 'login:fail private native detail' };
wxMock.login.mockImplementation(({ fail }) => {
fail(loginError);
});
const page = loadWebViewPage(wxMock);
await page.onLoad({ authAction: 'login', returnTo: 'previous' });
expect(page.data.errorMessage).toBe('微信登录失败,请稍后重试。');
expect(console.error).toHaveBeenCalledWith('[web-view] wx.login failed');
expect(console.error).toHaveBeenCalledWith('[web-view] auth flow failed');
expect(console.error.mock.calls.flat()).not.toContain(loginError);
expect(page.data.phoneBindingRequired).toBe(false);
});
test('绑定手机号失败不向页面透出后端错误体', async () => {
const wxMock = createWxMock();
const page = loadWebViewPage(wxMock);
page.data.authResult = {
token: 'jwt-pending-wechat',
bindingStatus: 'pending_bind_phone',
};
wxMock.request.mockImplementation(({ success }) => {
success({
statusCode: 500,
data: {
error: {
message: 'private backend detail',
},
},
});
});
await page.handleGetPhoneNumber({
detail: {
code: 'wechat-phone-code',
},
});
expect(page.data.errorMessage).toBe('绑定手机号失败,请稍后重试。');
expect(console.error).toHaveBeenCalledWith(
'[web-view] mini program bind phone failed',
);
expect(console.error).toHaveBeenCalledWith('[web-view] bind phone failed');
expect(console.error.mock.calls.flat()).not.toContain('private backend detail');
});
test('拒绝手机号授权不向页面透出微信原生错误', async () => {
const wxMock = createWxMock();
const page = loadWebViewPage(wxMock);
page.data.authResult = {
token: 'jwt-pending-wechat',
bindingStatus: 'pending_bind_phone',
};
const authDeclined = {
errMsg: 'getPhoneNumber:fail private native detail',
};
await page.handleGetPhoneNumber({
detail: authDeclined,
});
expect(page.data.errorMessage).toBe('需要授权手机号后才能完成绑定。');
expect(page.data.errorMessage).not.toContain('private native detail');
expect(console.error).toHaveBeenCalledWith(
'[web-view] bind phone auth declined',
);
expect(console.error.mock.calls.flat()).not.toContain(authDeclined);
});
});