背景:用户反馈(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。
368 lines
13 KiB
Plaintext
368 lines
13 KiB
Plaintext
// 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%')
|
||
}
|
||
} |