Files
SyncCalendar/entry/src/main/ets/pages/AddAccountPage.ets
T
laoyang ed26da8dc4 0.0.7:CalDAV 地址自动发现 + 人话错误提示 + 只读公开日历
背景:用户反馈(Nextcloud/内网)——DAVx5 用 CalDAV 地址能完整读取,换本应用
读不出日历;用"分享链接"绑定时提示服务器返回 405。

定位:不是协议问题,而是"填入的 URL 不是 CalDAV 端点"。
  ① 我们的发现=PROPFIND+Depth:1 只认 resourcetype 含 calendar 的集合;填 DAV 根
     /remote.php/dav 只能看到 principals/calendars/addressbooks → "未发现日历本"。
  ② 分享链接(/apps/calendar/p/<token>、/s/<token>)是网页路由 → PROPFIND 405。
  ③ 全工程此前无 .well-known 自动发现(grep 无命中)→ 必须手填完整路径,
     而 DAVx5 内置自动发现,故"DAVx5 能用、我们不能"。

实现(新增 common/DavDiscovery.ets):
1. .well-known/caldav 自动发现:207/200 直接用;301/302/307/308/401 读 Location
2. 智能补全:按 Nextcloud/ownCloud/Radicale/Baikal 等常见路径生成候选并逐个探测,
   保留子目录前缀(填 https://h/nextcloud → /nextcloud/remote.php/dav/calendars/<u>/)
3. 分享链接识别:/apps/calendar/p/、/s/、/index.php/s/ 直接拦下并给改法
4. OPTIONS 能力探测:读 DAV/Allow 头,区分"不是 DAV 端点"与"服务端挡了方法"
5. 状态码人话化:405/401/403/404/跳转/网络失败各有可操作指引;
   "未发现日历本"区分"地址差一层"(给出补全后的正确地址)与"确实为空"
6. 只读公开日历:识别 public-calendars 链接;只读由既有 calendarWritable 探测自动标记

接入三处:
- CalendarListPage.fetchCalendars / EditAccountPage:先 resolve() 定位端点,
  命中则自动修正 serverUrl(并落库),失败则直接给人话原因
- AddAccountPage:连接前拦分享链接;probeServer 遇 405 用 OPTIONS 判定并给指引;
  地址输入框提示同步更新

设计要点:发现顺序=原地址优先(保持既有行为、不破坏已能用的用户)→ .well-known
→ 常见候选;单个候选失败不抛错;ArkTS 静态方法内禁用 this → 统一用类名调用。

验证:编译通过;离线用例覆盖分类/完整性判定/候选路径(4 种输入形态)全部通过。

版本:AppScope/app.json5 → 0.0.7 / 700
文档:新增《上架更新说明-0.0.6到0.0.7.md》;官网 sf/uh 补功能说明与使用指引、
  版本号与日期更新、404 重建,已部署并线上核验。
打包:assembleApp release → verify-app success、profile type=release、
  app-distribution-type=app_gallery、包内版本 700/0.0.7。
2026-10-13 12:43:26 +08:00

368 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// entry/src/main/ets/pages/AddAccountPage.ets
// 添加账号第一页:URL / 用户名 / 密码 → 连接(凭据经 AppStorage 传给日历本选择页)
import { http } from '@kit.NetworkKit';
import { curves, router } from '@kit.ArkUI';
import { buffer } from '@kit.ArkTS';
import url from '@ohos.url';
import { BusinessError } from '@kit.BasicServicesKit';
import { TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore';
import { DavDiscovery, DavCapability } from '../common/DavDiscovery';
import { LogUtil } from '../common/LogUtil';
@Entry
@Component
struct AddAccountPage {
@State serverUrl: string = '';
@State username: string = '';
@State password: string = '';
@State isLoading: boolean = false;
@State statusMsg: string = '';
@State statusOk: boolean = false;
@State typeLabel: string = 'CalDAV';
private accountType: string = TYPE_CALDAV;
aboutToAppear(): void {
const t: string | undefined = AppStorage.get<string>('pendingAccountType');
this.accountType = (t === undefined || t === '') ? TYPE_CALDAV : t;
if (this.accountType === TYPE_CARDDAV) {
this.typeLabel = 'CardDAV';
} else if (this.accountType === TYPE_WEBDAV) {
this.typeLabel = 'WebDAV';
} else {
this.typeLabel = 'CalDAV';
}
}
private normalizeUrl(): string | null {
let rawUrl: string = this.serverUrl.trim();
if (rawUrl === '') {
return null;
}
if (!rawUrl.startsWith('http://') && !rawUrl.startsWith('https://')) {
rawUrl = 'https://' + rawUrl;
}
const hostPattern: RegExp =
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$|^\d{1,3}(\.\d{1,3}){3}$|^\[[0-9A-Fa-f:]+\]$|^localhost$/;
try {
const parsed = url.URL.parseURL(rawUrl);
const hostname: string = parsed.hostname !== '' ? parsed.hostname : parsed.host;
if (hostname !== '' && hostPattern.test(hostname)) {
return rawUrl;
}
this.statusMsg = `URL 主机名无效:${rawUrl}`;
this.statusOk = false;
return null;
} catch (err) {
const e = err as BusinessError;
console.error(`URL 解析异常(${e.code}),使用正则兜底: ${e.message}`);
const fallbackPattern: RegExp =
/^https?:\/\/[^\s/:?#]+(:\d{1,5})?([/?#][^\s]*)?$/;
if (fallbackPattern.test(rawUrl)) {
return rawUrl;
}
this.statusMsg = `URL 解析失败:${e.message}`;
this.statusOk = false;
return null;
}
}
private encodeBasicAuth(): string {
try {
return buffer.from(`${this.username}:${this.password}`).toString('base64');
} catch (err) {
const e = err as BusinessError;
console.error(`Base64 编码失败: ${e.message}`);
return '';
}
}
private async sendOnce(serverUrl: string, method: http.RequestMethod, authHeader: string): Promise<number> {
const httpRequest = http.createHttp();
try {
const resp: http.HttpResponse = await httpRequest.request(serverUrl, {
method: method,
header: {
'Authorization': authHeader,
'Accept': '*/*',
'User-Agent': 'SyncCalendar/1.0'
},
connectTimeout: 10000,
readTimeout: 10000
});
return resp.responseCode;
} finally {
httpRequest.destroy();
}
}
private async probeServer(serverUrl: string): Promise<boolean> {
const token: string = this.encodeBasicAuth();
if (token === '') {
this.statusMsg = '凭据编码失败:请在真机或模拟器上运行';
this.statusOk = false;
return false;
}
const authHeader: string = 'Basic ' + token;
try {
let code: number = await this.sendOnce(serverUrl, http.RequestMethod.OPTIONS, authHeader);
if (code === 401) {
code = await this.sendOnce(serverUrl, http.RequestMethod.GET, authHeader);
}
if (code === 401) {
this.statusMsg = '服务器拒绝凭据(401):请检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
this.statusOk = false;
return false;
}
// ⭐ 0.0.7:405 是"这里不是 DAV 端点"的典型信号 —— 直接给出可操作指引,
// 而不是笼统地报一个状态码让用户去猜。
if (code === 405) {
const cap: DavCapability = await DavDiscovery.optionsCapability(serverUrl, authHeader);
if (!cap.isDav) {
this.statusMsg = '这个地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。\n\n'
+ '请填写「日历家目录」而不是 DAV 根或网页链接,例如:\n'
+ 'https://你的域名/remote.php/dav/calendars/你的用户名/\n\n'
+ '(也可以直接填域名,下一步会自动尝试发现)';
this.statusOk = false;
return false;
}
}
if (code >= 200 && code < 500) {
// ⭐ 即使可连,也顺手提示"地址看起来不完整",让用户提前知道下一步会自动补全
if (DavDiscovery.looksIncomplete(serverUrl)) {
this.statusMsg = '服务器连接成功(该地址看起来是服务器根路径,下一步会自动尝试定位日历家目录)';
} else {
this.statusMsg = '服务器连接成功';
}
this.statusOk = true;
return true;
}
this.statusMsg = `服务器返回异常状态码:${code}`;
this.statusOk = false;
return false;
} catch (err) {
const e = err as BusinessError;
console.error(`连接失败: ${e.code} - ${e.message}`);
this.statusMsg = `无法连接服务器:${e.message}`;
this.statusOk = false;
return false;
}
}
private async onConnectAndSave(): Promise<void> {
if (this.isLoading) {
return;
}
const targetUrl: string | null = this.normalizeUrl();
if (targetUrl === null) {
if (this.statusMsg === '' || this.statusMsg === '正在连接服务器…' || this.statusOk) {
this.statusMsg = '请输入有效的服务器地址';
}
this.statusOk = false;
return;
}
if (!this.username.trim()) {
this.statusMsg = '请输入用户名';
this.statusOk = false;
return;
}
if (!this.password) {
this.statusMsg = '请输入密码';
this.statusOk = false;
return;
}
// 明文 HTTP 属于"已知不安全"的传输方式:Basic 凭据(用户名/密码)会以未加密形式
// 在网络上传输,可被同网络中间人直接读取。
// 不直接拒绝(局域网自建 NAS/测试环境常用 http),但必须让用户明确知情并二次确认,
// 避免用户在不知情下把凭据发到明文信道。
if (targetUrl.startsWith('http://')) {
this.getUIContext().showAlertDialog({
title: '不安全连接',
message: '该地址使用 http:// 明文连接,用户名与密码将以未加密方式在网络中传输,存在被窃听的风险。\n\n建议改用 https://。是否仍要继续?',
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '取消',
action: (): void => {
this.statusMsg = '已取消:建议改用 https:// 地址';
this.statusOk = false;
}
},
secondaryButton: {
value: '仍要继续',
fontColor: $r('app.color.error'),
action: (): void => {
this.doConnect(targetUrl);
}
}
});
return;
}
await this.doConnect(targetUrl);
}
/** 实际连接并(成功时)把凭据经 AppStorage 交给日历本选择页 */
private async doConnect(targetUrl: string): Promise<void> {
if (this.isLoading) {
return;
}
// ⭐ 0.0.7:先识别「网页分享链接」——这类地址对 PROPFIND 必定 405,
// 提前拦下并给出人话指引,避免用户白跑一趟只看到"服务器返回 405"。
const kind: string = DavDiscovery.classify(targetUrl);
const shareMsg: string = DavDiscovery.shareHint(kind);
if (shareMsg !== '') {
this.statusMsg = shareMsg;
this.statusOk = false;
LogUtil.write(`添加账号:识别为网页分享链接(${kind}),已拦截`);
return;
}
this.isLoading = true;
this.statusMsg = '正在连接服务器…';
this.statusOk = false;
const ok: boolean = await this.probeServer(targetUrl);
if (ok) {
AppStorage.setOrCreate<string>('pendingDavUrl', targetUrl);
AppStorage.setOrCreate<string>('pendingDavUsername', this.username.trim());
AppStorage.setOrCreate<string>('pendingDavPassword', this.password);
this.getUIContext().getPromptAction().showToast({ message: '连接成功' });
router.pushUrl({ url: 'pages/CalendarListPage' });
}
this.isLoading = false;
}
build() {
Column({ space: 24 }) {
Row({ space: 6 }) {
Text('←')
.fontSize(18)
.fontColor($r('app.color.brand'))
Text('返回')
.fontSize(16)
.fontColor($r('app.color.brand'))
}
.width('100%')
.onClick(() => {
router.back();
})
Column({ space: 8 }) {
Text(`添加${this.typeLabel}账号`)
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('输入服务器账号信息')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.width('100%')
Column({ space: 16 }) {
this.formField('服务器地址', '例如:https://nas.example.com/remote.php/dav/calendars/用户名/(也可只填域名,下一步会自动识别)', this.serverUrl,
(value: string) => {
this.serverUrl = value;
}, false)
this.formField('用户名', '请输入用户名', this.username,
(value: string) => {
this.username = value;
}, false)
this.formField('密码', '请输入密码', this.password,
(value: string) => {
this.password = value;
}, true)
}
.width('100%')
.padding(20)
.borderRadius(16)
.backgroundColor($r('app.color.card_bg'))
.shadow({ radius: 12, color: $r('app.color.shadow_color'), offsetX: 0, offsetY: 4 })
Button() {
Row({ space: 8 }) {
if (this.isLoading) {
LoadingProgress()
.width(20)
.height(20)
.color($r('app.color.button_text'))
}
Text(this.isLoading ? '连接中…' : '连接并保存')
.fontSize(17)
.fontColor($r('app.color.button_text'))
.fontWeight(FontWeight.Medium)
}
}
.width('100%')
.height(48)
.borderRadius(24)
.backgroundColor(this.isLoading ? $r('app.color.brand_disabled') : $r('app.color.brand'))
.enabled(!this.isLoading)
.onClick(() => {
this.onConnectAndSave();
})
if (this.statusMsg) {
Row({ space: 6 }) {
Text(this.statusOk ? '✓' : '✕')
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
Text(this.statusMsg)
.fontSize(14)
.fontColor(this.statusOk ? $r('app.color.success') : $r('app.color.error'))
}
.width('100%')
.padding(12)
.borderRadius(8)
.backgroundColor(this.statusOk ? $r('app.color.success_bg') : $r('app.color.error_bg'))
}
Blank()
}
.width('100%')
.height('100%')
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
.backgroundColor($r('app.color.page_bg'))
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.alignItems(HorizontalAlign.Center)
}
/**
* 页面转场:淡入淡出。
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
*/
pageTransition() {
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
.opacity(0);
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
.opacity(0);
}
@Builder
formField(label: string, placeholder: string, value: string,
onChange: (value: string) => void, isPassword: boolean) {
Column({ space: 8 }) {
Text(label)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.fontWeight(FontWeight.Medium)
TextInput({ text: value, placeholder: placeholder })
.type(isPassword ? InputType.Password : InputType.Normal)
.showPasswordIcon(isPassword)
.height(44)
.fontSize(15)
.backgroundColor($r('app.color.input_bg'))
.borderRadius(8)
// 显式指定文字/提示/光标颜色:默认取系统主题色,深色模式下会与底色撞色而"看不清"
.fontColor($r('app.color.text_primary'))
.placeholderColor($r('app.color.text_hint'))
.caretColor($r('app.color.brand'))
.onChange(onChange)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
}
}