release: prepare August 1 Android RC
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1",
|
||||
"version": "20260728",
|
||||
"prompt": "template",
|
||||
"title": "服务协议和隐私政策",
|
||||
"message": " 请你审慎阅读并充分理解《服务协议》和《隐私政策》。为了提供登录、录音转写、图片/视频/文件上传和故障排查服务,本应用会在你使用相应功能时请求必要的设备和文件访问权限。你可阅读<a href=\"__TERMS_URL__\">《服务协议》</a>和<a href=\"__PRIVACY_URL__\">《隐私政策》</a>了解详细信息。点击“同意并继续”即表示你同意上述协议。",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aihr-mobile-uni",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aihr-mobile-uni",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-alpha-5020220260725001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-alpha-5020220260725001",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package",
|
||||
"name": "aihr-mobile-uni",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -10,8 +10,10 @@
|
||||
"build:h5": "uni build -p h5",
|
||||
"build:app": "uni build -p app",
|
||||
"generate:app-assets": "sh scripts/generate-app-assets.sh",
|
||||
"configure:app-privacy": "node scripts/configure-android-privacy.mjs",
|
||||
"verify:app-assets": "node scripts/verify-app-assets.mjs",
|
||||
"verify:app-release": "node scripts/verify-app-assets.mjs --require-privacy",
|
||||
"verify:android-apk": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-android-apk.ps1",
|
||||
"verify:app-release": "node scripts/verify-app-assets.mjs --require-privacy --check-legal-urls",
|
||||
"test:unit": "node --test tests/*.test.mjs",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
const PLACEHOLDER_PATTERN = /__|change[-_ ]?me|your[-_ ]?|example\.(com|org|net)/i;
|
||||
const RESERVED_HOSTNAME_PATTERN = /(?:^|\.)(?:example|invalid|test)$/i;
|
||||
|
||||
const isPrivateIpv4 = (hostname) => {
|
||||
const parts = hostname.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return false;
|
||||
}
|
||||
return parts[0] === 0
|
||||
|| parts[0] === 10
|
||||
|| parts[0] === 127
|
||||
|| (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127)
|
||||
|| (parts[0] === 169 && parts[1] === 254)
|
||||
|| (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
||||
|| (parts[0] === 192 && parts[1] === 168)
|
||||
|| (parts[0] === 192 && parts[1] === 0 && (parts[2] === 0 || parts[2] === 2))
|
||||
|| (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19))
|
||||
|| (parts[0] === 198 && parts[1] === 51 && parts[2] === 100)
|
||||
|| (parts[0] === 203 && parts[1] === 0 && parts[2] === 113)
|
||||
|| parts[0] >= 224;
|
||||
};
|
||||
|
||||
const isPrivateHostname = (hostname) => {
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
const ipVersion = isIP(normalized);
|
||||
return normalized === 'localhost'
|
||||
|| normalized.endsWith('.localhost')
|
||||
|| (ipVersion === 6 && (
|
||||
normalized === '::1'
|
||||
|| /^f[cd]/.test(normalized)
|
||||
|| normalized.startsWith('fe80:')
|
||||
))
|
||||
|| (ipVersion === 4 && isPrivateIpv4(normalized));
|
||||
};
|
||||
|
||||
export const normalizeLegalUrl = (rawValue, label = 'legal URL') => {
|
||||
const value = String(rawValue || '').trim();
|
||||
if (!value) {
|
||||
throw new Error(`${label} is required`);
|
||||
}
|
||||
if (PLACEHOLDER_PATTERN.test(value)) {
|
||||
throw new Error(`${label} must not use a placeholder or example domain`);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${label} must be a valid absolute URL`);
|
||||
}
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new Error(`${label} must use HTTPS`);
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error(`${label} must not contain embedded credentials`);
|
||||
}
|
||||
if (RESERVED_HOSTNAME_PATTERN.test(parsed.hostname)) {
|
||||
throw new Error(`${label} must not use a reserved hostname`);
|
||||
}
|
||||
if (isPrivateHostname(parsed.hostname)) {
|
||||
throw new Error(`${label} must use a public hostname`);
|
||||
}
|
||||
return parsed.toString();
|
||||
};
|
||||
|
||||
export const validateLegalUrlPair = (termsValue, privacyValue) => {
|
||||
const termsUrl = normalizeLegalUrl(termsValue, 'service agreement URL');
|
||||
const privacyUrl = normalizeLegalUrl(privacyValue, 'privacy policy URL');
|
||||
if (termsUrl === privacyUrl) {
|
||||
throw new Error('service agreement URL and privacy policy URL must be different');
|
||||
}
|
||||
return { termsUrl, privacyUrl };
|
||||
};
|
||||
|
||||
const extractLinks = (message) =>
|
||||
[...String(message || '').matchAll(/href="([^"]+)"/g)].map((match) => match[1]);
|
||||
|
||||
export const validateAndroidPrivacyDocument = (privacy) => {
|
||||
if (!privacy || typeof privacy !== 'object') {
|
||||
throw new Error('androidPrivacy.json must contain a JSON object');
|
||||
}
|
||||
if (privacy.prompt !== 'template') {
|
||||
throw new Error('androidPrivacy.json must use prompt=template');
|
||||
}
|
||||
if (!/^[1-9]\d*$/.test(String(privacy.version || ''))) {
|
||||
throw new Error('androidPrivacy.json version must be a positive numeric policy version');
|
||||
}
|
||||
if (privacy.hrefLoader !== 'system') {
|
||||
throw new Error('androidPrivacy.json must open legal links with hrefLoader=system');
|
||||
}
|
||||
|
||||
const primaryLinks = extractLinks(privacy.message);
|
||||
const secondaryLinks = extractLinks(privacy.second?.message);
|
||||
if (primaryLinks.length !== 2 || secondaryLinks.length !== 2) {
|
||||
throw new Error('androidPrivacy.json must contain two legal links in both prompts');
|
||||
}
|
||||
|
||||
const primary = validateLegalUrlPair(primaryLinks[0], primaryLinks[1]);
|
||||
const secondary = validateLegalUrlPair(secondaryLinks[0], secondaryLinks[1]);
|
||||
if (primary.termsUrl !== secondary.termsUrl || primary.privacyUrl !== secondary.privacyUrl) {
|
||||
throw new Error('androidPrivacy.json primary and secondary prompts must use the same legal links');
|
||||
}
|
||||
return primary;
|
||||
};
|
||||
|
||||
export const renderAndroidPrivacy = (templateText, termsValue, privacyValue) => {
|
||||
const { termsUrl, privacyUrl } = validateLegalUrlPair(termsValue, privacyValue);
|
||||
const rendered = String(templateText)
|
||||
.replaceAll('__TERMS_URL__', termsUrl)
|
||||
.replaceAll('__PRIVACY_URL__', privacyUrl);
|
||||
const privacy = JSON.parse(rendered);
|
||||
validateAndroidPrivacyDocument(privacy);
|
||||
return `${JSON.stringify(privacy, null, 2)}\n`;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { renderAndroidPrivacy } from './app-legal-config.mjs';
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, '..');
|
||||
const templatePath = resolve(projectRoot, 'androidPrivacy.json.example');
|
||||
// uni-app CLI uses src/ as UNI_INPUT_DIR. The App-Plus compiler only copies
|
||||
// androidPrivacy.json from that input directory into the native build resource.
|
||||
const outputPath = resolve(projectRoot, 'src', 'androidPrivacy.json');
|
||||
|
||||
const argumentValue = (name) => {
|
||||
const prefix = `--${name}=`;
|
||||
return process.argv.find((argument) => argument.startsWith(prefix))?.slice(prefix.length);
|
||||
};
|
||||
|
||||
const termsUrl = argumentValue('terms-url') || process.env.VITE_APP_TERMS_URL;
|
||||
const privacyUrl = argumentValue('privacy-url') || process.env.VITE_APP_PRIVACY_URL;
|
||||
|
||||
try {
|
||||
const rendered = renderAndroidPrivacy(
|
||||
readFileSync(templatePath, 'utf8'),
|
||||
termsUrl,
|
||||
privacyUrl
|
||||
);
|
||||
writeFileSync(outputPath, rendered, 'utf8');
|
||||
console.log('src/androidPrivacy.json configured from final public legal URLs.');
|
||||
} catch (error) {
|
||||
console.error(`Android privacy configuration failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ApkPath,
|
||||
[ValidateSet('custom-base', 'dcloud-test', 'release')]
|
||||
[string]$BuildKind = 'custom-base',
|
||||
[string]$HBuilderHome,
|
||||
[string]$ExpectedSignerSha256,
|
||||
[string]$ExpectedTermsUrl,
|
||||
[string]$ExpectedPrivacyUrl
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$projectRoot = Split-Path -Parent $scriptDirectory
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ExpectedTermsUrl)) {
|
||||
$ExpectedTermsUrl = $env:VITE_APP_TERMS_URL
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($ExpectedPrivacyUrl)) {
|
||||
$ExpectedPrivacyUrl = $env:VITE_APP_PRIVACY_URL
|
||||
}
|
||||
if (
|
||||
[string]::IsNullOrWhiteSpace($ExpectedTermsUrl) -xor
|
||||
[string]::IsNullOrWhiteSpace($ExpectedPrivacyUrl)
|
||||
) {
|
||||
throw 'ExpectedTermsUrl and ExpectedPrivacyUrl must be provided together.'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ApkPath)) {
|
||||
$ApkPath = Join-Path $projectRoot 'dist\debug\android_debug.apk'
|
||||
}
|
||||
$resolvedApkPath = (Resolve-Path -LiteralPath $ApkPath).Path
|
||||
if ([IO.Path]::GetExtension($resolvedApkPath) -ne '.apk') {
|
||||
throw "Expected an .apk file: $resolvedApkPath"
|
||||
}
|
||||
if ((Get-Item -LiteralPath $resolvedApkPath).Length -lt 1MB) {
|
||||
throw "APK is unexpectedly small: $resolvedApkPath"
|
||||
}
|
||||
|
||||
$sourceManifestPath = Join-Path $projectRoot 'src\manifest.json'
|
||||
$sourceManifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $sourceManifestPath | ConvertFrom-Json
|
||||
$sourceAndroid = $sourceManifest.'app-plus'.distribute.android
|
||||
|
||||
$candidateHomes = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($HBuilderHome)) {
|
||||
$candidateHomes += $HBuilderHome
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:HBUILDERX_HOME)) {
|
||||
$candidateHomes += $env:HBUILDERX_HOME
|
||||
}
|
||||
$candidateHomes += 'D:\HBuilderX'
|
||||
$resolvedHBuilderHome = $candidateHomes |
|
||||
Where-Object { Test-Path -LiteralPath (Join-Path $_ 'plugins\app-safe-pack\apktool.jar') } |
|
||||
Select-Object -First 1
|
||||
if ([string]::IsNullOrWhiteSpace($resolvedHBuilderHome)) {
|
||||
throw 'HBuilderX app-safe-pack tools were not found. Pass -HBuilderHome or set HBUILDERX_HOME.'
|
||||
}
|
||||
|
||||
$java = Get-Command java -ErrorAction Stop
|
||||
$apktoolJar = Join-Path $resolvedHBuilderHome 'plugins\app-safe-pack\apktool.jar'
|
||||
$apksignerJar = Join-Path $resolvedHBuilderHome 'plugins\app-safe-pack\apksigner.jar'
|
||||
if (-not (Test-Path -LiteralPath $apksignerJar)) {
|
||||
throw "Missing APK signer verifier: $apksignerJar"
|
||||
}
|
||||
|
||||
$temporaryRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||
$decodePath = [IO.Path]::GetFullPath(
|
||||
(Join-Path $temporaryRoot ('bangdao-apk-verify-' + [Guid]::NewGuid().ToString('N')))
|
||||
)
|
||||
if (-not $decodePath.StartsWith($temporaryRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Refusing to use a temporary path outside the system temp directory: $decodePath"
|
||||
}
|
||||
|
||||
try {
|
||||
& $java.Source -jar $apktoolJar d -s -o $decodePath $resolvedApkPath 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "apktool failed to decode $resolvedApkPath"
|
||||
}
|
||||
|
||||
$decodedManifestPath = Join-Path $decodePath 'AndroidManifest.xml'
|
||||
$apktoolMetadataPath = Join-Path $decodePath 'apktool.yml'
|
||||
[xml]$decodedManifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $decodedManifestPath
|
||||
$decodedManifestText = Get-Content -Raw -Encoding UTF8 -LiteralPath $decodedManifestPath
|
||||
$apktoolMetadata = Get-Content -Raw -Encoding UTF8 -LiteralPath $apktoolMetadataPath
|
||||
|
||||
$packageName = $decodedManifest.manifest.package
|
||||
$targetSdkMatch = [regex]::Match($apktoolMetadata, '(?m)^\s*targetSdkVersion:\s*(\d+)\s*$')
|
||||
$versionCodeMatch = [regex]::Match($apktoolMetadata, '(?m)^\s*versionCode:\s*(\d+)\s*$')
|
||||
$versionNameMatch = [regex]::Match($apktoolMetadata, '(?m)^\s*versionName:\s*(\S+)\s*$')
|
||||
if (-not $targetSdkMatch.Success -or -not $versionCodeMatch.Success -or -not $versionNameMatch.Success) {
|
||||
throw 'APK metadata is missing targetSdkVersion, versionCode, or versionName.'
|
||||
}
|
||||
|
||||
$targetSdkVersion = [int]$targetSdkMatch.Groups[1].Value
|
||||
$versionCode = $versionCodeMatch.Groups[1].Value
|
||||
$versionName = $versionNameMatch.Groups[1].Value.Trim("'`"")
|
||||
if ($packageName -ne $sourceAndroid.packagename) {
|
||||
throw "APK package mismatch: got $packageName, expected $($sourceAndroid.packagename)"
|
||||
}
|
||||
if ($targetSdkVersion -ne [int]$sourceAndroid.targetSdkVersion) {
|
||||
throw "APK targetSdkVersion mismatch: got $targetSdkVersion, expected $($sourceAndroid.targetSdkVersion)"
|
||||
}
|
||||
if ($versionCode -ne [string]$sourceManifest.versionCode) {
|
||||
throw "APK versionCode mismatch: got $versionCode, expected $($sourceManifest.versionCode)"
|
||||
}
|
||||
if ($versionName -ne [string]$sourceManifest.versionName) {
|
||||
throw "APK versionName mismatch: got $versionName, expected $($sourceManifest.versionName)"
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExpectedTermsUrl)) {
|
||||
if ($BuildKind -eq 'custom-base') {
|
||||
# A DCloud custom base APK contains only the native debug runtime. HBuilderX
|
||||
# syncs the compiled www resources separately when it runs the app on a
|
||||
# device, so the legal links cannot truthfully be asserted from the APK.
|
||||
$compiledPrivacyPath = Join-Path $projectRoot 'dist\build\app\androidPrivacy.json'
|
||||
if (-not (Test-Path -LiteralPath $compiledPrivacyPath)) {
|
||||
throw 'Compiled App resources are missing androidPrivacy.json; run build:app first.'
|
||||
}
|
||||
$privacyJson = Get-Content -Raw -Encoding UTF8 -LiteralPath $compiledPrivacyPath
|
||||
}
|
||||
else {
|
||||
$bundledPrivacyFiles = @(
|
||||
Get-ChildItem -LiteralPath (Join-Path $decodePath 'assets\apps') `
|
||||
-Recurse -File -Filter 'androidPrivacy.json'
|
||||
)
|
||||
if ($bundledPrivacyFiles.Count -ne 1) {
|
||||
throw "Expected exactly one bundled androidPrivacy.json, found $($bundledPrivacyFiles.Count)."
|
||||
}
|
||||
$privacyJson = Get-Content -Raw -Encoding UTF8 -LiteralPath $bundledPrivacyFiles[0].FullName
|
||||
}
|
||||
|
||||
if (-not $privacyJson.Contains($ExpectedTermsUrl)) {
|
||||
throw 'Packaged privacy configuration is missing the expected service agreement URL.'
|
||||
}
|
||||
if (-not $privacyJson.Contains($ExpectedPrivacyUrl)) {
|
||||
throw 'Packaged privacy configuration is missing the expected privacy policy URL.'
|
||||
}
|
||||
$privacyConfig = $privacyJson | ConvertFrom-Json
|
||||
if ($privacyConfig.prompt -ne 'template') {
|
||||
throw 'Packaged privacy configuration must enable the DCloud native template prompt.'
|
||||
}
|
||||
}
|
||||
|
||||
if ($BuildKind -ne 'custom-base') {
|
||||
$bundledAppServiceFiles = @(
|
||||
Get-ChildItem -LiteralPath (Join-Path $decodePath 'assets\apps') `
|
||||
-Recurse -File -Filter 'app-service.js'
|
||||
)
|
||||
if ($bundledAppServiceFiles.Count -ne 1) {
|
||||
throw "Expected exactly one bundled app-service.js, found $($bundledAppServiceFiles.Count)."
|
||||
}
|
||||
$bundledAppServiceSha256 = (
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath $bundledAppServiceFiles[0].FullName
|
||||
).Hash
|
||||
$compiledAppServiceCandidates = @(
|
||||
(Join-Path $projectRoot 'dist\build\app\app-service.js'),
|
||||
(Join-Path $projectRoot 'dist\build\app-plus\app-service.js')
|
||||
) | Where-Object { Test-Path -LiteralPath $_ }
|
||||
if ($compiledAppServiceCandidates.Count -eq 0) {
|
||||
throw 'Compiled App resources are missing app-service.js; run build:app or HBuilderX pack first.'
|
||||
}
|
||||
$matchingCompiledAppService = $compiledAppServiceCandidates |
|
||||
Where-Object {
|
||||
(Get-FileHash -Algorithm SHA256 -LiteralPath $_).Hash -eq $bundledAppServiceSha256
|
||||
} |
|
||||
Select-Object -First 1
|
||||
if ([string]::IsNullOrWhiteSpace($matchingCompiledAppService)) {
|
||||
throw 'APK app-service.js does not match the current compiled App resource.'
|
||||
}
|
||||
}
|
||||
|
||||
$sensitiveFiles = @(
|
||||
Get-ChildItem -LiteralPath $decodePath -Recurse -File |
|
||||
Where-Object {
|
||||
$_.Name -match '^(?:\.env(?:\..*)?|id_rsa)$' -or
|
||||
$_.Extension -match '^\.(?:jks|keystore|p12|pfx|pem)$'
|
||||
}
|
||||
)
|
||||
if ($sensitiveFiles.Count -gt 0) {
|
||||
throw "APK contains a sensitive file: $($sensitiveFiles[0].Name)"
|
||||
}
|
||||
$sensitiveRules = [ordered]@{
|
||||
'private key' = 'BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY'
|
||||
'cloud access key' = '\b(?:AKIA|ASIA)[A-Z0-9]{16}\b'
|
||||
'GitHub token' = '\b(?:ghp_|github_pat_)[A-Za-z0-9_]{20,}\b'
|
||||
'model API key' = '\bsk-[A-Za-z0-9_-]{20,}\b'
|
||||
'mobile number' = '(?<!\d)1[3-9]\d{9}(?!\d)'
|
||||
'fixed test SMS code' = '\u6D4B\u8BD5\u9A8C\u8BC1\u7801.{0,24}\b\d{4,8}\b'
|
||||
}
|
||||
$textExtensions = @('.css', '.html', '.js', '.json', '.txt', '.xml')
|
||||
foreach ($file in Get-ChildItem -LiteralPath $decodePath -Recurse -File) {
|
||||
if ($textExtensions -notcontains $file.Extension.ToLowerInvariant()) {
|
||||
continue
|
||||
}
|
||||
$content = [IO.File]::ReadAllText($file.FullName)
|
||||
foreach ($rule in $sensitiveRules.GetEnumerator()) {
|
||||
if ([regex]::IsMatch($content, $rule.Value)) {
|
||||
$relativeFile = $file.FullName.Substring($decodePath.Length).TrimStart('\', '/')
|
||||
throw "APK contains a possible $($rule.Key): $relativeFile"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$androidNamespace = 'http://schemas.android.com/apk/res/android'
|
||||
$application = $decodedManifest.manifest.application
|
||||
$debuggable = $application.GetAttribute('debuggable', $androidNamespace) -eq 'true'
|
||||
$usesCleartextTraffic = $application.GetAttribute('usesCleartextTraffic', $androidNamespace) -eq 'true'
|
||||
if ($BuildKind -eq 'custom-base' -and -not $debuggable) {
|
||||
throw 'Custom base APK must remain debuggable.'
|
||||
}
|
||||
if ($BuildKind -eq 'dcloud-test' -and $debuggable) {
|
||||
throw 'DCloud internal test APK must not be debuggable.'
|
||||
}
|
||||
if ($BuildKind -eq 'release') {
|
||||
if ($debuggable) {
|
||||
throw 'Release APK must not be debuggable.'
|
||||
}
|
||||
if ($usesCleartextTraffic) {
|
||||
throw 'Release APK must not allow cleartext HTTP traffic.'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($ExpectedSignerSha256)) {
|
||||
throw 'Release verification requires -ExpectedSignerSha256 for the enterprise signing certificate.'
|
||||
}
|
||||
}
|
||||
|
||||
$signatureOutput = & $java.Source -jar $apksignerJar verify --verbose --print-certs $resolvedApkPath 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0 -or $signatureOutput -notmatch 'Verified using v2 scheme \(APK Signature Scheme v2\): true') {
|
||||
throw 'APK signature verification failed or APK Signature Scheme v2 is missing.'
|
||||
}
|
||||
if ($signatureOutput -notmatch 'Number of signers:\s*1') {
|
||||
throw 'APK must have exactly one signer.'
|
||||
}
|
||||
$signerMatch = [regex]::Match($signatureOutput, 'Signer #1 certificate SHA-256 digest:\s*([0-9a-fA-F]+)')
|
||||
if (-not $signerMatch.Success) {
|
||||
throw 'APK signer certificate SHA-256 digest could not be read.'
|
||||
}
|
||||
$signerSha256 = $signerMatch.Groups[1].Value.ToUpperInvariant()
|
||||
if (
|
||||
$BuildKind -eq 'release' -and
|
||||
$signerSha256 -ne $ExpectedSignerSha256.Replace(':', '').ToUpperInvariant()
|
||||
) {
|
||||
throw "APK signer mismatch: got $signerSha256"
|
||||
}
|
||||
|
||||
$apkSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedApkPath).Hash
|
||||
Write-Output 'Android APK verification passed.'
|
||||
Write-Output " kind: $BuildKind"
|
||||
Write-Output " package: $packageName"
|
||||
Write-Output " version: $versionName ($versionCode)"
|
||||
Write-Output " targetSdkVersion: $targetSdkVersion"
|
||||
Write-Output " usesCleartextTraffic: $usesCleartextTraffic"
|
||||
Write-Output " signer SHA-256: $signerSha256"
|
||||
Write-Output " APK SHA-256: $apkSha256"
|
||||
Write-Output " path: $resolvedApkPath"
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $decodePath) {
|
||||
$resolvedDecodePath = [IO.Path]::GetFullPath($decodePath)
|
||||
if (
|
||||
$resolvedDecodePath.StartsWith($temporaryRoot, [StringComparison]::OrdinalIgnoreCase) -and
|
||||
$resolvedDecodePath -ne $temporaryRoot
|
||||
) {
|
||||
Remove-Item -LiteralPath $resolvedDecodePath -Recurse -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,77 @@
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import {
|
||||
validateAndroidPrivacyDocument,
|
||||
validateLegalUrlPair
|
||||
} from './app-legal-config.mjs';
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, '..');
|
||||
const manifestPath = resolve(projectRoot, 'src/manifest.json');
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
const requirePrivacy = process.argv.includes('--require-privacy');
|
||||
const checkLegalUrls = process.argv.includes('--check-legal-urls');
|
||||
const failures = [];
|
||||
let releaseLegalUrls;
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
const compiledAppRoot = resolve(projectRoot, 'dist/build/app');
|
||||
const releaseTextExtensions = new Set(['.css', '.html', '.js', '.json', '.txt', '.xml']);
|
||||
const releaseSensitiveFilePattern = /(^|[\\/])(?:\.env(?:\.|$)|id_rsa$)|\.(?:jks|keystore|p12|pfx|pem)$/i;
|
||||
const releaseSensitiveContentPatterns = [
|
||||
['private key', /BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY/],
|
||||
['cloud access key', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/],
|
||||
['GitHub token', /\b(?:ghp_|github_pat_)[A-Za-z0-9_]{20,}\b/],
|
||||
['model API key', /\bsk-[A-Za-z0-9_-]{20,}\b/],
|
||||
['mobile number', /(?<!\d)1[3-9]\d{9}(?!\d)/],
|
||||
['fixed test SMS code', /测试验证码.{0,24}\b\d{4,8}\b/]
|
||||
];
|
||||
|
||||
const compiledTextFiles = (root) => {
|
||||
if (!existsSync(root)) return [];
|
||||
const result = [];
|
||||
const visit = (directory) => {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolutePath = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(absolutePath);
|
||||
} else if (releaseTextExtensions.has(entry.name.slice(entry.name.lastIndexOf('.')).toLowerCase())) {
|
||||
result.push(absolutePath);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return result;
|
||||
};
|
||||
|
||||
const scanCompiledAppForSensitiveValues = () => {
|
||||
if (!existsSync(compiledAppRoot)) {
|
||||
fail('compiled App resource directory is missing; run build:app before release verification');
|
||||
return;
|
||||
}
|
||||
const allFiles = [];
|
||||
const visit = (directory) => {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolutePath = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) visit(absolutePath);
|
||||
else allFiles.push(absolutePath);
|
||||
}
|
||||
};
|
||||
visit(compiledAppRoot);
|
||||
for (const file of allFiles) {
|
||||
const relativePath = relative(compiledAppRoot, file);
|
||||
if (releaseSensitiveFilePattern.test(relativePath)) {
|
||||
fail(`compiled App contains a sensitive file: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
for (const file of compiledTextFiles(compiledAppRoot)) {
|
||||
const content = readFileSync(file, 'utf8');
|
||||
for (const [label, pattern] of releaseSensitiveContentPatterns) {
|
||||
if (pattern.test(content)) {
|
||||
fail(`compiled App contains a possible ${label}: ${relative(compiledAppRoot, file)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const readPng = (relativePath) => {
|
||||
if (typeof relativePath !== 'string' || !relativePath) {
|
||||
fail('manifest is missing an App asset path');
|
||||
@@ -49,10 +113,54 @@ const app = manifest['app-plus'];
|
||||
const distribute = app?.distribute;
|
||||
const icons = distribute?.icons;
|
||||
const splashscreen = distribute?.splashscreen;
|
||||
const android = distribute?.android;
|
||||
const modules = app?.modules;
|
||||
|
||||
if (!Array.isArray(app?.screenOrientation) || !app.screenOrientation.includes('portrait-primary')) {
|
||||
fail('app-plus.screenOrientation must include portrait-primary');
|
||||
}
|
||||
if (splashscreen?.useOriginalMsgbox !== true) {
|
||||
fail('app-plus.distribute.splashscreen.useOriginalMsgbox must enable the native privacy prompt');
|
||||
}
|
||||
if (app?.ssl?.untrustedca !== 'refuse') {
|
||||
fail('app-plus.ssl.untrustedca must remain refuse');
|
||||
}
|
||||
if (!/^\d+\.\d+\.\d+$/.test(String(manifest.versionName || ''))) {
|
||||
fail('versionName must use semantic numeric form such as 0.1.6');
|
||||
}
|
||||
if (!/^[1-9]\d*$/.test(String(manifest.versionCode || ''))) {
|
||||
fail('versionCode must be a positive integer');
|
||||
}
|
||||
if (android?.packagename !== 'com.yincheng.wygj') {
|
||||
fail('Android package name must remain com.yincheng.wygj');
|
||||
}
|
||||
if (android?.targetSdkVersion !== 35) {
|
||||
fail('Android targetSdkVersion must be 35 for the August 1 test package');
|
||||
}
|
||||
const expectedAndroidAbis = ['arm64-v8a', 'armeabi-v7a'];
|
||||
const actualAndroidAbis = Array.isArray(android?.abiFilters)
|
||||
? [...android.abiFilters].sort()
|
||||
: [];
|
||||
if (JSON.stringify(actualAndroidAbis) !== JSON.stringify(expectedAndroidAbis)) {
|
||||
fail('Android abiFilters must include exactly armeabi-v7a and arm64-v8a');
|
||||
}
|
||||
if (android?.permissionExternalStorage?.request !== 'none') {
|
||||
fail('Android external storage permission must not be requested at startup');
|
||||
}
|
||||
if (android?.permissionPhoneState?.request !== 'none') {
|
||||
fail('Android phone state permission must not be requested at startup');
|
||||
}
|
||||
if (!Object.hasOwn(modules || {}, 'Camera') || !Object.hasOwn(modules || {}, 'Record')) {
|
||||
fail('App modules must include Camera and Record for user-triggered media features');
|
||||
}
|
||||
const declaredAndroidPermissions = Array.isArray(android?.permissions)
|
||||
? android.permissions.join('\n')
|
||||
: '';
|
||||
for (const permission of ['android.permission.RECORD_AUDIO', 'android.permission.MODIFY_AUDIO_SETTINGS']) {
|
||||
if (!declaredAndroidPermissions.includes(permission)) {
|
||||
fail(`Android permissions must include ${permission}`);
|
||||
}
|
||||
}
|
||||
|
||||
const androidIcons = [
|
||||
['hdpi', 72], ['xhdpi', 96], ['xxhdpi', 144], ['xxxhdpi', 192]
|
||||
@@ -85,21 +193,102 @@ const splashPaths = [
|
||||
];
|
||||
for (const [relativePath, width, height] of splashPaths) expectPng(relativePath, width, height);
|
||||
|
||||
const privacyPath = resolve(projectRoot, 'androidPrivacy.json');
|
||||
const privacyPath = resolve(projectRoot, 'src/androidPrivacy.json');
|
||||
const compiledPrivacyPath = resolve(projectRoot, 'dist/build/app/androidPrivacy.json');
|
||||
if (requirePrivacy) {
|
||||
if (String(process.env.VITE_DEV_SMS_HINT_ENABLED || '').toLowerCase() === 'true') {
|
||||
fail('VITE_DEV_SMS_HINT_ENABLED must be disabled for release builds');
|
||||
}
|
||||
if (String(process.env.VITE_DEV_SMS_HINT_VALUE || '').trim()) {
|
||||
fail('VITE_DEV_SMS_HINT_VALUE must not be present for release builds');
|
||||
}
|
||||
try {
|
||||
releaseLegalUrls = validateLegalUrlPair(
|
||||
process.env.VITE_APP_TERMS_URL,
|
||||
process.env.VITE_APP_PRIVACY_URL
|
||||
);
|
||||
} catch (error) {
|
||||
fail(`release build legal URL environment is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (!existsSync(privacyPath)) {
|
||||
fail('androidPrivacy.json is required for release; copy androidPrivacy.json.example and replace both legal links');
|
||||
fail('src/androidPrivacy.json is required for release; run configure:app-privacy with final legal URLs');
|
||||
} else {
|
||||
try {
|
||||
const privacy = JSON.parse(readFileSync(privacyPath, 'utf8'));
|
||||
const links = [...String(privacy.message || '').matchAll(/href="([^"]+)"/g)].map((match) => match[1]);
|
||||
if (privacy.prompt !== 'template' || links.length < 2 || links.some((link) => !/^https:\/\//.test(link) || link.includes('__'))) {
|
||||
fail('androidPrivacy.json must use prompt=template and two final HTTPS legal links');
|
||||
const privacyLegalUrls = validateAndroidPrivacyDocument(privacy);
|
||||
if (
|
||||
releaseLegalUrls
|
||||
&& (
|
||||
privacyLegalUrls.termsUrl !== releaseLegalUrls.termsUrl
|
||||
|| privacyLegalUrls.privacyUrl !== releaseLegalUrls.privacyUrl
|
||||
)
|
||||
) {
|
||||
fail('androidPrivacy.json legal links must match VITE_APP_TERMS_URL and VITE_APP_PRIVACY_URL');
|
||||
}
|
||||
} catch {
|
||||
fail('androidPrivacy.json must be valid JSON without comments');
|
||||
releaseLegalUrls = privacyLegalUrls;
|
||||
} catch (error) {
|
||||
fail(`androidPrivacy.json is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(compiledPrivacyPath)) {
|
||||
fail('compiled App resource is missing androidPrivacy.json; run build:app after configure:app-privacy');
|
||||
} else {
|
||||
try {
|
||||
const compiledPrivacy = JSON.parse(readFileSync(compiledPrivacyPath, 'utf8'));
|
||||
const compiledLegalUrls = validateAndroidPrivacyDocument(compiledPrivacy);
|
||||
if (
|
||||
releaseLegalUrls
|
||||
&& (
|
||||
compiledLegalUrls.termsUrl !== releaseLegalUrls.termsUrl
|
||||
|| compiledLegalUrls.privacyUrl !== releaseLegalUrls.privacyUrl
|
||||
)
|
||||
) {
|
||||
fail('compiled App androidPrivacy.json must match the source privacy configuration');
|
||||
}
|
||||
} catch (error) {
|
||||
fail(`compiled App androidPrivacy.json is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
scanCompiledAppForSensitiveValues();
|
||||
}
|
||||
|
||||
const checkRemoteLegalPage = async (url, label, expectedText) => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
headers: { 'user-agent': 'Bangdao-App-Release-Preflight/1.0' }
|
||||
});
|
||||
if (!response.ok) {
|
||||
fail(`${label} returned HTTP ${response.status}`);
|
||||
return;
|
||||
}
|
||||
if (!String(response.url).startsWith('https://')) {
|
||||
fail(`${label} redirected away from HTTPS`);
|
||||
return;
|
||||
}
|
||||
const contentType = String(response.headers.get('content-type') || '').toLowerCase();
|
||||
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) {
|
||||
fail(`${label} must return an HTML document`);
|
||||
return;
|
||||
}
|
||||
const body = await response.text();
|
||||
if (body.trim().length < 100 || !expectedText.test(body)) {
|
||||
fail(`${label} does not contain recognizable final legal content`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail(`${label} is not publicly reachable: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (requirePrivacy && checkLegalUrls && releaseLegalUrls) {
|
||||
await Promise.all([
|
||||
checkRemoteLegalPage(releaseLegalUrls.termsUrl, 'service agreement URL', /服务协议|用户协议/),
|
||||
checkRemoteLegalPage(releaseLegalUrls.privacyUrl, 'privacy policy URL', /隐私政策|个人信息保护/)
|
||||
]);
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
|
||||
Vendored
+5
@@ -1,7 +1,12 @@
|
||||
/// <reference types="@dcloudio/types" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly DEV: boolean;
|
||||
readonly VITE_API_BASE?: string;
|
||||
readonly VITE_APP_TERMS_URL?: string;
|
||||
readonly VITE_APP_PRIVACY_URL?: string;
|
||||
readonly VITE_DEV_SMS_HINT_ENABLED?: string;
|
||||
readonly VITE_DEV_SMS_HINT_VALUE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"appid" : "__UNI__B36D8BE",
|
||||
"description" : "帮道员工端",
|
||||
"vueVersion" : "3",
|
||||
"versionName" : "0.1.5",
|
||||
"versionCode" : "105",
|
||||
"versionName" : "0.1.6",
|
||||
"versionCode" : "106",
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
@@ -59,6 +59,7 @@
|
||||
},
|
||||
"splashscreen" : {
|
||||
"iosStyle" : "default",
|
||||
"useOriginalMsgbox" : true,
|
||||
"ios" : {
|
||||
"iphone" : {
|
||||
"retina40" : "src/static/app/splash/ios-iphone-retina40.png",
|
||||
@@ -87,7 +88,14 @@
|
||||
},
|
||||
"android" : {
|
||||
"packagename" : "com.yincheng.wygj",
|
||||
"targetSdkVersion" : 35,
|
||||
"abiFilters" : [ "armeabi-v7a", "arm64-v8a" ],
|
||||
"permissionExternalStorage" : {
|
||||
"request" : "none"
|
||||
},
|
||||
"permissionPhoneState" : {
|
||||
"request" : "none"
|
||||
},
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>"
|
||||
|
||||
@@ -74,9 +74,9 @@
|
||||
</uni-forms-item>
|
||||
</uni-forms>
|
||||
|
||||
<view class="dev-hint-pill" @click="fillDevCredentials">
|
||||
<view v-if="showDevSmsHint" class="dev-hint-pill" @click="fillDevCredentials">
|
||||
<uni-icons type="info-filled" size="14" color="#d97706" />
|
||||
<text class="dev-hint-text">测试验证码: 123456</text>
|
||||
<text class="dev-hint-text">测试验证码: {{ devSmsHintValue }}</text>
|
||||
<text class="dev-fill-btn">快捷填入</text>
|
||||
</view>
|
||||
|
||||
@@ -93,12 +93,25 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="agreement-row" @click="agreed = !agreed">
|
||||
<view class="checkbox-box" :class="{ checked: agreed }">
|
||||
<uni-icons v-if="agreed" type="checkmarkempty" size="12" color="#ffffff" />
|
||||
<view class="agreement-row">
|
||||
<view
|
||||
class="agreement-consent"
|
||||
role="checkbox"
|
||||
:aria-checked="agreed"
|
||||
@click="agreed = !agreed"
|
||||
>
|
||||
<view class="checkbox-box" :class="{ checked: agreed }">
|
||||
<uni-icons v-if="agreed" type="checkmarkempty" size="12" color="#ffffff" />
|
||||
</view>
|
||||
<text class="agreement-text">已阅读并同意</text>
|
||||
</view>
|
||||
<text class="agreement-text">已阅读并同意《服务协议》与《隐私政策》</text>
|
||||
<text class="agreement-link" @click.stop="openLegalDocument('terms')">《服务协议》</text>
|
||||
<text class="agreement-text">与</text>
|
||||
<text class="agreement-link" @click.stop="openLegalDocument('privacy')">《隐私政策》</text>
|
||||
</view>
|
||||
<text v-if="!legalLinksReady" class="agreement-error">
|
||||
协议地址尚未完成法务配置,当前暂停验证码发送与登录。
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -109,6 +122,11 @@ import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type { LoginResult } from '@/types/api';
|
||||
import { apiRequest } from '@/services/api';
|
||||
import { consumeLoginRedirect, getAuth, isLoggedIn, phoneKey, saveAuth } from '@/services/auth';
|
||||
import {
|
||||
legalDocumentUrl,
|
||||
legalLinksReady,
|
||||
type LegalDocumentKind
|
||||
} from '@/services/legal';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { goToMobileTarget, nextUserRouteAfterLogin } from '@/services/position';
|
||||
|
||||
@@ -135,8 +153,14 @@ const formRules = {
|
||||
const sending = ref(false);
|
||||
const loggingIn = ref(false);
|
||||
const countdown = ref(0);
|
||||
const agreed = ref(true);
|
||||
const agreed = ref(false);
|
||||
const message = ref('');
|
||||
const devSmsHintValue = import.meta.env.DEV
|
||||
? String(import.meta.env.VITE_DEV_SMS_HINT_VALUE || '').replace(/\D/g, '').slice(0, 6)
|
||||
: '';
|
||||
const showDevSmsHint = import.meta.env.DEV
|
||||
&& import.meta.env.VITE_DEV_SMS_HINT_ENABLED === 'true'
|
||||
&& /^\d{4,6}$/.test(devSmsHintValue);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const inputStyles = {
|
||||
@@ -166,8 +190,9 @@ const startCountdown = () => {
|
||||
};
|
||||
|
||||
const fillDevCredentials = () => {
|
||||
formData.smsCode = '123456';
|
||||
message.value = '已自动填入测试验证码 123456';
|
||||
if (!showDevSmsHint) return;
|
||||
formData.smsCode = devSmsHintValue;
|
||||
message.value = '已自动填入本地测试验证码';
|
||||
};
|
||||
|
||||
const normalizeDigits = (value: string | number, maxLength: number) =>
|
||||
@@ -183,6 +208,33 @@ const onSmsCodeInput = (value: string | number) => {
|
||||
formData.smsCode = normalizeDigits(value, 6);
|
||||
};
|
||||
|
||||
const ensureLegalConsent = () => {
|
||||
if (!legalLinksReady) {
|
||||
message.value = '服务协议和隐私政策地址尚未配置,暂不能发送验证码或登录';
|
||||
return false;
|
||||
}
|
||||
if (!agreed.value) {
|
||||
message.value = '请先阅读并勾选同意《服务协议》与《隐私政策》';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const openLegalDocument = (kind: LegalDocumentKind) => {
|
||||
const url = legalDocumentUrl(kind);
|
||||
if (!url) {
|
||||
message.value = '协议地址尚未完成法务配置';
|
||||
return;
|
||||
}
|
||||
if (typeof plus !== 'undefined') {
|
||||
plus.runtime.openURL(url);
|
||||
return;
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const goAfterLogin = async () => {
|
||||
const redirect = consumeLoginRedirect() || '/pages/user/today/index';
|
||||
goToMobileTarget(await nextUserRouteAfterLogin(redirect));
|
||||
@@ -190,6 +242,7 @@ const goAfterLogin = async () => {
|
||||
|
||||
const sendCode = async () => {
|
||||
if (sending.value || countdown.value > 0) return;
|
||||
if (!ensureLegalConsent()) return;
|
||||
try {
|
||||
await loginForm.value?.validateField(['phone']);
|
||||
} catch {
|
||||
@@ -215,10 +268,7 @@ const sendCode = async () => {
|
||||
|
||||
const login = async () => {
|
||||
if (loggingIn.value) return;
|
||||
if (!agreed.value) {
|
||||
message.value = '请先勾选同意《服务协议》与《隐私政策》';
|
||||
return;
|
||||
}
|
||||
if (!ensureLegalConsent()) return;
|
||||
try {
|
||||
await loginForm.value?.validate();
|
||||
} catch {
|
||||
@@ -616,9 +666,16 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
gap: 2px;
|
||||
max-width: 720px;
|
||||
margin: 18px auto 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.agreement-consent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -647,6 +704,24 @@ onUnmounted(() => {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.agreement-link {
|
||||
color: var(--employee-primary, #e60012);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agreement-error {
|
||||
display: block;
|
||||
max-width: 720px;
|
||||
margin: 8px auto 0;
|
||||
color: #b45309;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 375px) {
|
||||
.login-shell {
|
||||
padding-right: 20px;
|
||||
|
||||
@@ -426,7 +426,7 @@ const submit = async () => {
|
||||
form.requestId = '';
|
||||
form.content = '';
|
||||
voiceHint.value = '';
|
||||
submitMessage.value = '反馈已提交,可在“我的反馈”查看处理结果。';
|
||||
submitMessage.value = '已收到,当前状态为“待处理”;可在“我的反馈”查看人工正式回复。';
|
||||
await loadMine(true);
|
||||
} catch (error) {
|
||||
submitFailed.value = true;
|
||||
|
||||
@@ -517,7 +517,11 @@ type ChatMsg = UserMsg | MasterMsg | SystemMsg | PendingMsg;
|
||||
|
||||
const positionProfile = ref(getSelectedPositionProfile());
|
||||
const positionLabel = ref(getSelectedPosition());
|
||||
const quickQuestions = computed(() => positionProfile.value.quickQuestions);
|
||||
const policyQuickQuestion = '问制度:请说明公司请假和考勤的适用规定';
|
||||
const quickQuestions = computed(() => [
|
||||
policyQuickQuestion,
|
||||
...positionProfile.value.quickQuestions.filter((item) => item !== policyQuickQuestion)
|
||||
].slice(0, 4));
|
||||
const quickQuestionPlaceholder = computed(() => `例如: ${quickQuestions.value[0] || '岗位SOP怎么处理?'}`);
|
||||
|
||||
const messages = ref<ChatMsg[]>([]);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
export type LegalDocumentKind = 'terms' | 'privacy';
|
||||
|
||||
const placeholderPattern = /__|change[-_ ]?me|your[-_ ]?|example\.(com|org|net)/i;
|
||||
const reservedHostnamePattern = /(?:^|\.)(?:example|invalid|test)$/i;
|
||||
|
||||
const ipv4Parts = (hostname: string) => {
|
||||
const parts = hostname.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return null;
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
const isReservedIpv4 = (parts: number[]) => {
|
||||
return parts[0] === 0
|
||||
|| parts[0] === 10
|
||||
|| parts[0] === 127
|
||||
|| (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127)
|
||||
|| (parts[0] === 169 && parts[1] === 254)
|
||||
|| (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
||||
|| (parts[0] === 192 && parts[1] === 168)
|
||||
|| (parts[0] === 192 && parts[1] === 0 && (parts[2] === 0 || parts[2] === 2))
|
||||
|| (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19))
|
||||
|| (parts[0] === 198 && parts[1] === 51 && parts[2] === 100)
|
||||
|| (parts[0] === 203 && parts[1] === 0 && parts[2] === 113)
|
||||
|| parts[0] >= 224;
|
||||
};
|
||||
|
||||
const normalizePublicHttpsUrl = (rawValue?: string) => {
|
||||
const value = String(rawValue || '').trim();
|
||||
if (!value || placeholderPattern.test(value)) return '';
|
||||
if (/[\u0000-\u0020\u007f]/.test(value)) return '';
|
||||
|
||||
const match = /^https:\/\/([^/?#]+)(?:[/?#]|$)/i.exec(value);
|
||||
if (!match || match[1].includes('@')) return '';
|
||||
const authority = match[1];
|
||||
let hostname = '';
|
||||
if (authority.startsWith('[')) {
|
||||
const end = authority.indexOf(']');
|
||||
if (end <= 1 || (authority.slice(end + 1) && !/^:\d+$/.test(authority.slice(end + 1)))) return '';
|
||||
hostname = authority.slice(1, end).toLowerCase();
|
||||
} else {
|
||||
const hostAndPort = authority.toLowerCase();
|
||||
const colon = hostAndPort.lastIndexOf(':');
|
||||
if (colon >= 0) {
|
||||
if (!/^\d+$/.test(hostAndPort.slice(colon + 1)) || hostAndPort.slice(0, colon).includes(':')) return '';
|
||||
hostname = hostAndPort.slice(0, colon);
|
||||
} else {
|
||||
hostname = hostAndPort;
|
||||
}
|
||||
}
|
||||
|
||||
const numericIpv4 = /^[0-9.]+$/.test(hostname);
|
||||
const parsedIpv4 = numericIpv4 ? ipv4Parts(hostname) : null;
|
||||
if (
|
||||
!hostname
|
||||
|| reservedHostnamePattern.test(hostname)
|
||||
|| hostname === 'localhost'
|
||||
|| hostname.endsWith('.localhost')
|
||||
|| hostname === '::1'
|
||||
|| (numericIpv4 && (!parsedIpv4 || isReservedIpv4(parsedIpv4)))
|
||||
|| (hostname.includes(':') && (!/^[0-9a-f:]+$/i.test(hostname) || /^f[cd]/.test(hostname) || hostname.startsWith('fe80:')))
|
||||
|| (!numericIpv4 && !hostname.includes(':') && (
|
||||
!hostname.includes('.')
|
||||
|| !/^[a-z0-9.-]+$/i.test(hostname)
|
||||
|| hostname.startsWith('.')
|
||||
|| hostname.endsWith('.')
|
||||
))
|
||||
) return '';
|
||||
return value;
|
||||
};
|
||||
|
||||
const termsUrl = normalizePublicHttpsUrl(import.meta.env.VITE_APP_TERMS_URL);
|
||||
const privacyUrl = normalizePublicHttpsUrl(import.meta.env.VITE_APP_PRIVACY_URL);
|
||||
|
||||
export const legalLinksReady = Boolean(termsUrl && privacyUrl && termsUrl !== privacyUrl);
|
||||
|
||||
export const legalDocumentUrl = (kind: LegalDocumentKind) => {
|
||||
if (!legalLinksReady) return '';
|
||||
return kind === 'terms' ? termsUrl : privacyUrl;
|
||||
};
|
||||
@@ -4,6 +4,11 @@ import { readFile } from 'node:fs/promises';
|
||||
import { test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import {
|
||||
renderAndroidPrivacy,
|
||||
validateAndroidPrivacyDocument,
|
||||
validateLegalUrlPair
|
||||
} from '../scripts/app-legal-config.mjs';
|
||||
|
||||
const mobileRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const readJson = async (path) => JSON.parse(await readFile(new URL(path, import.meta.url), 'utf8'));
|
||||
@@ -30,3 +35,128 @@ test('App 平台编译器已声明且与其他 uni 包同版本,避免 build:a
|
||||
assert.equal(appPlus, pkg.dependencies['@dcloudio/uni-h5']);
|
||||
assert.equal(appPlus, pkg.devDependencies['@dcloudio/vite-plugin-uni']);
|
||||
});
|
||||
|
||||
test('Android 自定义基座显式面向 Android 15 权限模型', async () => {
|
||||
const manifest = await readJson('../src/manifest.json');
|
||||
const android = manifest['app-plus']?.distribute?.android;
|
||||
|
||||
assert.equal(android?.packagename, 'com.yincheng.wygj');
|
||||
assert.equal(android?.targetSdkVersion, 35, '不得回退到 DCloud 默认的 targetSdkVersion 28 兼容模式');
|
||||
});
|
||||
|
||||
test('Android 启动阶段不主动索取设备信息或外部存储权限', async () => {
|
||||
const manifest = await readJson('../src/manifest.json');
|
||||
const android = manifest['app-plus']?.distribute?.android;
|
||||
const modules = manifest['app-plus']?.modules;
|
||||
|
||||
assert.equal(manifest['app-plus']?.distribute?.splashscreen?.useOriginalMsgbox, true);
|
||||
assert.equal(android?.permissionPhoneState?.request, 'none');
|
||||
assert.equal(android?.permissionExternalStorage?.request, 'none');
|
||||
assert.ok(Object.hasOwn(modules || {}, 'Camera'), '媒体功能仍需按用户操作动态申请相机权限');
|
||||
assert.ok(Object.hasOwn(modules || {}, 'Record'), '语音功能仍需按用户操作动态申请录音权限');
|
||||
});
|
||||
|
||||
test('正式法务地址必须使用不同的公网 HTTPS 页面', () => {
|
||||
assert.throws(() => validateLegalUrlPair('', 'https://legal.example.cn/privacy'), /required/);
|
||||
assert.throws(
|
||||
() => validateLegalUrlPair('http://legal.example.cn/terms', 'https://legal.example.cn/privacy'),
|
||||
/HTTPS/
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLegalUrlPair('https://127.0.0.1/terms', 'https://legal.example.cn/privacy'),
|
||||
/public hostname/
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLegalUrlPair('https://legal.example.cn/document', 'https://legal.example.cn/document'),
|
||||
/must be different/
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLegalUrlPair('https://198.18.0.1/terms', 'https://legal.example.cn/privacy'),
|
||||
/public hostname/
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLegalUrlPair('https://legal.invalid/terms', 'https://legal.example.cn/privacy'),
|
||||
/reserved hostname/
|
||||
);
|
||||
assert.doesNotThrow(() => validateLegalUrlPair(
|
||||
'https://198.51.99.1/terms',
|
||||
'https://fd-company.cn/privacy'
|
||||
));
|
||||
assert.doesNotThrow(() => validateLegalUrlPair(
|
||||
'https://fd-company.cn/terms',
|
||||
'https://fd-company.cn/privacy'
|
||||
));
|
||||
});
|
||||
|
||||
test('Android 隐私模板的首次与二次弹窗使用同一组最终链接', async () => {
|
||||
const template = await readFile(new URL('../androidPrivacy.json.example', import.meta.url), 'utf8');
|
||||
const rendered = renderAndroidPrivacy(
|
||||
template,
|
||||
'https://legal.company.cn/bangdao/terms',
|
||||
'https://legal.company.cn/bangdao/privacy'
|
||||
);
|
||||
const privacy = JSON.parse(rendered);
|
||||
|
||||
assert.deepEqual(validateAndroidPrivacyDocument(privacy), {
|
||||
termsUrl: 'https://legal.company.cn/bangdao/terms',
|
||||
privacyUrl: 'https://legal.company.cn/bangdao/privacy'
|
||||
});
|
||||
assert.doesNotMatch(rendered, /__TERMS_URL__|__PRIVACY_URL__/);
|
||||
|
||||
privacy.second.message = privacy.second.message.replace('/privacy', '/other-privacy');
|
||||
assert.throws(() => validateAndroidPrivacyDocument(privacy), /must use the same legal links/);
|
||||
});
|
||||
|
||||
test('Android 隐私配置写入 uni-app CLI 输入目录并校验编译产物', async () => {
|
||||
const configureScript = await readFile(
|
||||
new URL('../scripts/configure-android-privacy.mjs', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const verifier = await readFile(
|
||||
new URL('../scripts/verify-app-assets.mjs', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.match(configureScript, /resolve\(projectRoot, 'src', 'androidPrivacy\.json'\)/);
|
||||
assert.match(verifier, /dist\/build\/app\/androidPrivacy\.json/);
|
||||
});
|
||||
|
||||
test('App 登录页法务地址校验不依赖浏览器 URL 构造器', async () => {
|
||||
const legalService = await readFile(
|
||||
new URL('../src/services/legal.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.doesNotMatch(legalService, /new URL\(/);
|
||||
assert.match(legalService, /\^https:\\\/\\\/\(\[\^\/\?#\]\+\)/);
|
||||
assert.match(legalService, /legalLinksReady/);
|
||||
});
|
||||
|
||||
test('APK 门禁区分自定义调试基座与内置业务资源的测试包', async () => {
|
||||
const verifier = await readFile(
|
||||
new URL('../scripts/verify-android-apk.ps1', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.match(verifier, /ValidateSet\('custom-base', 'dcloud-test', 'release'\)/);
|
||||
assert.match(verifier, /assets\\apps/);
|
||||
assert.match(verifier, /bundled androidPrivacy\.json/);
|
||||
assert.match(verifier, /DCloud custom base APK contains only the native debug runtime/);
|
||||
assert.match(verifier, /dist\\build\\app\\app-service\.js/);
|
||||
assert.match(verifier, /dist\\build\\app-plus\\app-service\.js/);
|
||||
assert.match(verifier, /fixed test SMS code/);
|
||||
assert.match(verifier, /'mobile number'\s*=/);
|
||||
});
|
||||
|
||||
test('8 月 1 日 RC 脚本默认拒绝脏工作区且记录完整构建证据', async () => {
|
||||
const script = await readFile(
|
||||
new URL('../../scripts/prepare-aug1-rc.ps1', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.match(script, /requires a clean worktree/);
|
||||
assert.match(script, /AllowDirtyRehearsal/);
|
||||
assert.match(script, /RC build changed the previously clean worktree/);
|
||||
assert.match(script, /releaseEligible = \$releaseEligible/);
|
||||
assert.match(script, /APK metadata, legal links, signature, content match and sensitive-value scan/);
|
||||
});
|
||||
|
||||
@@ -92,9 +92,9 @@ test('候选端刷新失败不保留上一轮真实记录', async () => {
|
||||
pageSource('../src/pages/candidate/progress/index.vue')
|
||||
]);
|
||||
|
||||
assert.match(home, /catch \(error\) \{\n\s*clearData\(\);/);
|
||||
assert.match(materials, /catch \(error\) \{\n\s*clearData\(\);/);
|
||||
assert.match(progress, /catch \(error\) \{\n\s*clearData\(\);/);
|
||||
assert.match(home, /catch \(error\) \{\r?\n\s*clearData\(\);/);
|
||||
assert.match(materials, /catch \(error\) \{\r?\n\s*clearData\(\);/);
|
||||
assert.match(progress, /catch \(error\) \{\r?\n\s*clearData\(\);/);
|
||||
});
|
||||
|
||||
test('面试、资料、进度和预习页各自只承载一类真实任务', async () => {
|
||||
@@ -136,7 +136,7 @@ test('五个候选页面登录都使用 redirectTo 并保留当前任务页', as
|
||||
]);
|
||||
|
||||
sources.forEach((source) => {
|
||||
const goLogin = source.match(/const goLogin = \(\) => \{\n[\s\S]*?\n\};/)?.[0] || '';
|
||||
const goLogin = source.match(/const goLogin = \(\) => \{\r?\n[\s\S]*?\r?\n\};/)?.[0] || '';
|
||||
assert.match(goLogin, /rememberLoginRedirect\(currentPath\)/);
|
||||
assert.match(goLogin, /uni\.redirectTo\(\{ url: '\/pages\/auth\/login\/index' \}\)/);
|
||||
assert.doesNotMatch(goLogin, /uni\.navigateTo\(/);
|
||||
|
||||
@@ -161,6 +161,28 @@ test('移动端短信登录不再让客户端选择租户', async () => {
|
||||
assert.doesNotMatch(loginRequest, /tenantId:/);
|
||||
});
|
||||
|
||||
test('登录前协议默认不勾选且协议未配置时禁止发送验证码和登录', async () => {
|
||||
const source = await pageSource('../src/pages/auth/login/index.vue');
|
||||
|
||||
assert.match(source, /const agreed = ref\(false\)/);
|
||||
assert.match(source, /openLegalDocument\('terms'\)/);
|
||||
assert.match(source, /openLegalDocument\('privacy'\)/);
|
||||
assert.match(source, /const ensureLegalConsent = \(\) =>/);
|
||||
assert.match(source, /const sendCode = async \(\) => \{[\s\S]*?if \(!ensureLegalConsent\(\)\) return;/);
|
||||
assert.match(source, /const login = async \(\) => \{[\s\S]*?if \(!ensureLegalConsent\(\)\) return;/);
|
||||
assert.match(source, /协议地址尚未完成法务配置/);
|
||||
});
|
||||
|
||||
test('测试验证码提示必须由开发环境显式开关启用', async () => {
|
||||
const source = await pageSource('../src/pages/auth/login/index.vue');
|
||||
|
||||
assert.match(source, /v-if="showDevSmsHint"/);
|
||||
assert.match(source, /import\.meta\.env\.VITE_DEV_SMS_HINT_VALUE/);
|
||||
assert.match(source, /import\.meta\.env\.VITE_DEV_SMS_HINT_ENABLED === 'true'/);
|
||||
assert.match(source, /\/\^\\d\{4,6\}\$\/\.test\(devSmsHintValue\)/);
|
||||
assert.doesNotMatch(source, /123456/);
|
||||
});
|
||||
|
||||
test('共享视觉基座包含员工端主色和卡片圆角 token', async () => {
|
||||
const source = await pageSource('../src/styles/base.css');
|
||||
|
||||
@@ -691,9 +713,9 @@ test('直通车页支持语音输入并管控切页清理与字数上限', async
|
||||
assert.match(source, /slice\(0, MAX_CONTENT_LENGTH\)/);
|
||||
|
||||
// stop→onCapture 异步窗口:stopping 状态禁用重复 stop,避免 App 端二次 stop 使录音丢失。
|
||||
const stopBody = bodyOf(/const stopVoiceCapture = \(\) => \{([\s\S]*?)\};\n/);
|
||||
const stopBody = bodyOf(/const stopVoiceCapture = \(\) => \{([\s\S]*?)\};\r?\n/);
|
||||
assert.match(stopBody, /voiceStopping\.value = true/);
|
||||
const toggleBody = bodyOf(/const toggleVoiceCapture = \(\) => \{([\s\S]*?)\};\n/);
|
||||
const toggleBody = bodyOf(/const toggleVoiceCapture = \(\) => \{([\s\S]*?)\};\r?\n/);
|
||||
assert.match(toggleBody, /voiceStopping\.value/);
|
||||
|
||||
// 切页清理:onHide/onUnload 各自函数体内必须直接调用 teardownVoice(不允许跨函数误匹配)。
|
||||
@@ -705,7 +727,7 @@ test('直通车页支持语音输入并管控切页清理与字数上限', async
|
||||
assert.match(onUnloadBody, /teardownVoice\(\)/);
|
||||
|
||||
// teardown 必须同时处理:generation 递增、转写 abort、录音 cancel、状态复位、计时器停止。
|
||||
const teardownBody = bodyOf(/const teardownVoice = \(\) => \{([\s\S]*?)\};\n/);
|
||||
const teardownBody = bodyOf(/const teardownVoice = \(\) => \{([\s\S]*?)\};\r?\n/);
|
||||
assert.match(teardownBody, /voiceGeneration \+= 1/);
|
||||
assert.match(teardownBody, /abortVoiceTranscription\?\.\(\)/);
|
||||
assert.match(teardownBody, /voiceCapture\?\.cancel\(\)/);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
test('问师傅首屏提供政策制度快捷问题且继续走统一 Agent 请求', async () => {
|
||||
const [page, service] = await Promise.all([
|
||||
read('../src/pages/user/sop/index.vue'),
|
||||
read('../src/services/agent.ts')
|
||||
]);
|
||||
|
||||
assert.match(page, /const policyQuickQuestion = '问制度:请说明公司请假和考勤的适用规定'/);
|
||||
assert.match(page, /void sendQuestion\(value\)/);
|
||||
assert.match(service, /\/api\/aihr\/agent\/messages/);
|
||||
assert.doesNotMatch(page, /toolCode:\s*['"]POLICY/);
|
||||
});
|
||||
|
||||
test('直通车系统回执明确区分已收到、待处理和人工正式回复', async () => {
|
||||
const page = await read('../src/pages/user/direct/index.vue');
|
||||
|
||||
assert.match(page, /已收到,当前状态为“待处理”;可在“我的反馈”查看人工正式回复/);
|
||||
assert.match(page, /item\.status === 'REPLIED' \? '已回复' : '待处理'/);
|
||||
assert.match(page, /<text class="reply-label">正式回复<\/text>/);
|
||||
});
|
||||
|
||||
test('内置兜底与管理端演示不宣称帮道自动创建或派发工单', async () => {
|
||||
const [sopSeed, modelSeed, adminDemo] = await Promise.all([
|
||||
read('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java'),
|
||||
read('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java'),
|
||||
read('../../frontend/src/views/knowledge/sop.vue')
|
||||
]);
|
||||
const combined = [sopSeed, modelSeed, adminDemo].join('\n');
|
||||
|
||||
assert.match(combined, /既有工单系统/);
|
||||
assert.doesNotMatch(combined, /再创建工单/);
|
||||
assert.doesNotMatch(combined, /再生成工单闭环/);
|
||||
assert.match(modelSeed, /当前系统不会代为创建或派发工单/);
|
||||
});
|
||||
@@ -559,7 +559,7 @@ test('五个主管页面登录都使用 redirectTo 避免返回栈重复', () =>
|
||||
];
|
||||
|
||||
pages.forEach((source) => {
|
||||
const goLogin = source.match(/const goLogin = \(\) => \{\n[\s\S]*?\n\};/)?.[0] || '';
|
||||
const goLogin = source.match(/const goLogin = \(\) => \{\r?\n[\s\S]*?\r?\n\};/)?.[0] || '';
|
||||
assert.match(goLogin, /rememberLoginRedirect\(/);
|
||||
assert.match(goLogin, /uni\.redirectTo\(\{ url: '\/pages\/auth\/login\/index' \}\)/);
|
||||
assert.doesNotMatch(goLogin, /uni\.navigateTo\(/);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
@@ -141,13 +142,13 @@ test('工作上报服务声明整理契约并在正式提交携带 requestId', a
|
||||
|
||||
test('幂等键与异步账号守卫是可执行行为,不只是源码字符串', async (context) => {
|
||||
const { createServer } = await import('vite');
|
||||
const root = new URL('../', import.meta.url).pathname;
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: false,
|
||||
appType: 'custom',
|
||||
logLevel: 'silent',
|
||||
resolve: { alias: { '@': new URL('../src', import.meta.url).pathname } },
|
||||
resolve: { alias: { '@': fileURLToPath(new URL('../src', import.meta.url)) } },
|
||||
server: { middlewareMode: true }
|
||||
});
|
||||
context.after(() => server.close());
|
||||
|
||||
Reference in New Issue
Block a user