Compare commits
25 Commits
master
...
cross-plat
Author | SHA1 | Date |
---|---|---|
|
6b38b1a347 | 4 weeks ago |
|
ca455e9593 | 4 weeks ago |
|
5ada70b05e | 1 month ago |
|
4f9b146a66 | 1 month ago |
|
2b638ce2a7 | 1 month ago |
|
0b528af2a6 | 1 month ago |
|
008211bc21 | 1 month ago |
|
6955a36458 | 1 month ago |
|
ba079ea983 | 1 month ago |
|
d7b3c5ec9d | 1 month ago |
|
d83a25f47e | 1 month ago |
|
fb40dc0ff7 | 1 month ago |
|
d03fa55001 | 1 month ago |
|
c8eff4d39e | 1 month ago |
|
b8a7771edf | 1 month ago |
|
5d845fb112 | 1 month ago |
|
660f2170de | 1 month ago |
|
94bd649003 | 1 month ago |
|
b2d628cfeb | 1 month ago |
|
00e52f8dca | 1 month ago |
|
073ce24f43 | 1 month ago |
|
2c84bb50b3 | 1 month ago |
|
abf18835f6 | 1 month ago |
|
f72562804d | 1 month ago |
|
bdc5ffafc1 | 1 month ago |
@ -1,2 +1,2 @@ |
|||||
#Fri Mar 21 07:27:50 UTC 2025 |
#Wed Apr 09 09:01:13 UTC 2025 |
||||
gradle.version=8.2.1 |
gradle.version=8.11.1 |
||||
|
@ -1,17 +0,0 @@ |
|||||
<!DOCTYPE html> |
|
||||
<html lang=""> |
|
||||
<head> |
|
||||
<meta charset="utf-8"> |
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"> |
|
||||
<link rel="icon" href="/favicon.ico"> |
|
||||
<title>TimeSafari</title> |
|
||||
<script type="module" crossorigin src="/assets/index-CZMUlUNO.js"></script> |
|
||||
</head> |
|
||||
<body> |
|
||||
<noscript> |
|
||||
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> |
|
||||
</noscript> |
|
||||
<div id="app"></div> |
|
||||
</body> |
|
||||
</html> |
|
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 7.3 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 4.3 KiB |
After Width: | Height: | Size: 6.9 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 23 KiB |
@ -1,23 +1,16 @@ |
|||||
App/build |
App/build |
||||
App/Pods |
|
||||
App/output |
App/output |
||||
App/App/public |
App/Pods |
||||
DerivedData |
|
||||
xcuserdata |
|
||||
*.xcuserstate |
|
||||
|
|
||||
# Cordova plugins for Capacitor |
App/*.xcodeproj/xcuserdata/ |
||||
capacitor-cordova-ios-plugins |
App/*.xcworkspace/xcuserdata/ |
||||
|
App/*/public |
||||
|
|
||||
# Generated Config files |
# Generated Config files |
||||
App/App/capacitor.config.json |
App/*/capacitor.config.json |
||||
App/App/config.xml |
App/*/config.xml |
||||
|
|
||||
# User-specific Xcode files |
# Cordova plugins for Capacitor |
||||
App/App.xcodeproj/xcuserdata/*.xcuserdatad/ |
capacitor-cordova-ios-plugins |
||||
App/App.xcodeproj/*.xcuserstate |
|
||||
|
|
||||
fastlane/report.xml |
DerivedData |
||||
fastlane/Preview.html |
|
||||
fastlane/screenshots |
|
||||
fastlane/test_output |
|
||||
|
@ -1,8 +0,0 @@ |
|||||
<?xml version="1.0" encoding="UTF-8"?> |
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
||||
<plist version="1.0"> |
|
||||
<dict> |
|
||||
<key>IDEDidComputeMac32BitWarning</key> |
|
||||
<true/> |
|
||||
</dict> |
|
||||
</plist> |
|
@ -1,28 +1,52 @@ |
|||||
PODS: |
PODS: |
||||
- Capacitor (6.2.0): |
- Capacitor (6.2.1): |
||||
- CapacitorCordova |
- CapacitorCordova |
||||
- CapacitorApp (6.0.2): |
- CapacitorApp (6.0.2): |
||||
- Capacitor |
- Capacitor |
||||
- CapacitorCordova (6.2.0) |
- CapacitorCamera (6.1.2): |
||||
|
- Capacitor |
||||
|
- CapacitorCordova (6.2.1) |
||||
|
- CapacitorFilesystem (6.0.3): |
||||
|
- Capacitor |
||||
|
- CapacitorShare (6.0.3): |
||||
|
- Capacitor |
||||
|
- CapawesomeCapacitorFilePicker (6.2.0): |
||||
|
- Capacitor |
||||
|
|
||||
DEPENDENCIES: |
DEPENDENCIES: |
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)" |
- "Capacitor (from `../../node_modules/@capacitor/ios`)" |
||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)" |
- "CapacitorApp (from `../../node_modules/@capacitor/app`)" |
||||
|
- "CapacitorCamera (from `../../node_modules/@capacitor/camera`)" |
||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" |
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" |
||||
|
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)" |
||||
|
- "CapacitorShare (from `../../node_modules/@capacitor/share`)" |
||||
|
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)" |
||||
|
|
||||
EXTERNAL SOURCES: |
EXTERNAL SOURCES: |
||||
Capacitor: |
Capacitor: |
||||
:path: "../../node_modules/@capacitor/ios" |
:path: "../../node_modules/@capacitor/ios" |
||||
CapacitorApp: |
CapacitorApp: |
||||
:path: "../../node_modules/@capacitor/app" |
:path: "../../node_modules/@capacitor/app" |
||||
|
CapacitorCamera: |
||||
|
:path: "../../node_modules/@capacitor/camera" |
||||
CapacitorCordova: |
CapacitorCordova: |
||||
:path: "../../node_modules/@capacitor/ios" |
:path: "../../node_modules/@capacitor/ios" |
||||
|
CapacitorFilesystem: |
||||
|
:path: "../../node_modules/@capacitor/filesystem" |
||||
|
CapacitorShare: |
||||
|
:path: "../../node_modules/@capacitor/share" |
||||
|
CapawesomeCapacitorFilePicker: |
||||
|
:path: "../../node_modules/@capawesome/capacitor-file-picker" |
||||
|
|
||||
SPEC CHECKSUMS: |
SPEC CHECKSUMS: |
||||
Capacitor: 05d35014f4425b0740fc8776481f6a369ad071bf |
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf |
||||
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7 |
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7 |
||||
CapacitorCordova: b33e7f4aa4ed105dd43283acdd940964374a87d9 |
CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79 |
||||
|
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff |
||||
|
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74 |
||||
|
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e |
||||
|
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd |
||||
|
|
||||
PODFILE CHECKSUM: 4233f5c5f414604460ff96d372542c311b0fb7a8 |
PODFILE CHECKSUM: 1e9280368fd410520414f5741bf8fdfe7847b965 |
||||
|
|
||||
COCOAPODS: 1.16.2 |
COCOAPODS: 1.16.2 |
||||
|
@ -0,0 +1,22 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Clean the public directory |
||||
|
rm -rf android/app/src/main/assets/public/* |
||||
|
|
||||
|
# Copy web assets |
||||
|
cp -r dist/* android/app/src/main/assets/public/ |
||||
|
|
||||
|
# Ensure the directory structure exists |
||||
|
mkdir -p android/app/src/main/assets/public/assets |
||||
|
|
||||
|
# Copy the main index file |
||||
|
cp dist/index.html android/app/src/main/assets/public/ |
||||
|
|
||||
|
# Copy all assets |
||||
|
cp -r dist/assets/* android/app/src/main/assets/public/assets/ |
||||
|
|
||||
|
# Copy other necessary files |
||||
|
cp dist/favicon.ico android/app/src/main/assets/public/ |
||||
|
cp dist/robots.txt android/app/src/main/assets/public/ |
||||
|
|
||||
|
echo "Web assets copied successfully!" |
@ -0,0 +1,22 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Create directories if they don't exist |
||||
|
mkdir -p android/app/src/main/res/mipmap-mdpi |
||||
|
mkdir -p android/app/src/main/res/mipmap-hdpi |
||||
|
mkdir -p android/app/src/main/res/mipmap-xhdpi |
||||
|
mkdir -p android/app/src/main/res/mipmap-xxhdpi |
||||
|
mkdir -p android/app/src/main/res/mipmap-xxxhdpi |
||||
|
|
||||
|
# Generate placeholder icons using ImageMagick |
||||
|
convert -size 48x48 xc:blue -gravity center -pointsize 20 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-mdpi/ic_launcher.png |
||||
|
convert -size 72x72 xc:blue -gravity center -pointsize 30 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-hdpi/ic_launcher.png |
||||
|
convert -size 96x96 xc:blue -gravity center -pointsize 40 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xhdpi/ic_launcher.png |
||||
|
convert -size 144x144 xc:blue -gravity center -pointsize 60 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png |
||||
|
convert -size 192x192 xc:blue -gravity center -pointsize 80 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png |
||||
|
|
||||
|
# Copy to round versions |
||||
|
cp android/app/src/main/res/mipmap-mdpi/ic_launcher.png android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png |
||||
|
cp android/app/src/main/res/mipmap-hdpi/ic_launcher.png android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png |
||||
|
cp android/app/src/main/res/mipmap-xhdpi/ic_launcher.png android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png |
||||
|
cp android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png |
||||
|
cp android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png |
@ -0,0 +1,196 @@ |
|||||
|
/** * Data Export Section Component * * Provides UI and functionality for |
||||
|
exporting user data and backing up identifier seeds. * Includes buttons for seed |
||||
|
backup and database export, with platform-specific download instructions. * * |
||||
|
@component * @displayName DataExportSection * @example * ```vue * |
||||
|
<DataExportSection :active-did="currentDid" /> |
||||
|
* ``` */ |
||||
|
|
||||
|
<template> |
||||
|
<div |
||||
|
id="sectionDataExport" |
||||
|
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8" |
||||
|
> |
||||
|
<div class="mb-2 font-bold">Data Export</div> |
||||
|
<router-link |
||||
|
v-if="activeDid" |
||||
|
:to="{ name: 'seed-backup' }" |
||||
|
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2" |
||||
|
> |
||||
|
Backup Identifier Seed |
||||
|
</router-link> |
||||
|
|
||||
|
<button |
||||
|
:class="computedStartDownloadLinkClassNames()" |
||||
|
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md" |
||||
|
@click="exportDatabase()" |
||||
|
> |
||||
|
Download Settings & Contacts |
||||
|
<br /> |
||||
|
(excluding Identifier Data) |
||||
|
</button> |
||||
|
<a |
||||
|
ref="downloadLink" |
||||
|
:class="computedDownloadLinkClassNames()" |
||||
|
class="block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6" |
||||
|
> |
||||
|
If no download happened yet, click again here to download now. |
||||
|
</a> |
||||
|
<div v-if="platformCapabilities.needsFileHandlingInstructions" class="mt-4"> |
||||
|
<p> |
||||
|
After the download, you can save the file in your preferred storage |
||||
|
location. |
||||
|
</p> |
||||
|
<ul> |
||||
|
<li |
||||
|
v-if="platformCapabilities.isIOS" |
||||
|
class="list-disc list-outside ml-4" |
||||
|
> |
||||
|
On iOS: You will be prompted to choose a location to save your backup |
||||
|
file. |
||||
|
</li> |
||||
|
<li |
||||
|
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS" |
||||
|
class="list-disc list-outside ml-4" |
||||
|
> |
||||
|
On Android: You will be prompted to choose a location to save your |
||||
|
backup file. |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Prop, Vue } from "vue-facing-decorator"; |
||||
|
import { NotificationIface } from "../constants/app"; |
||||
|
import { db } from "../db/index"; |
||||
|
import { logger } from "../utils/logger"; |
||||
|
import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; |
||||
|
import { |
||||
|
PlatformService, |
||||
|
PlatformCapabilities, |
||||
|
} from "../services/PlatformService"; |
||||
|
|
||||
|
/** |
||||
|
* @vue-component |
||||
|
* Data Export Section Component |
||||
|
* Handles database export and seed backup functionality with platform-specific behavior |
||||
|
*/ |
||||
|
@Component |
||||
|
export default class DataExportSection extends Vue { |
||||
|
/** |
||||
|
* Notification function injected by Vue |
||||
|
* Used to show success/error messages to the user |
||||
|
*/ |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
/** |
||||
|
* Active DID (Decentralized Identifier) of the user |
||||
|
* Controls visibility of seed backup option |
||||
|
* @required |
||||
|
*/ |
||||
|
@Prop({ required: true }) readonly activeDid!: string; |
||||
|
|
||||
|
/** |
||||
|
* URL for the database export download |
||||
|
* Created and revoked dynamically during export process |
||||
|
* Only used in web platform |
||||
|
*/ |
||||
|
downloadUrl = ""; |
||||
|
|
||||
|
/** |
||||
|
* Platform service instance for platform-specific operations |
||||
|
*/ |
||||
|
private platformService: PlatformService = |
||||
|
PlatformServiceFactory.getInstance(); |
||||
|
|
||||
|
/** |
||||
|
* Platform capabilities for the current platform |
||||
|
*/ |
||||
|
private get platformCapabilities(): PlatformCapabilities { |
||||
|
return this.platformService.getCapabilities(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Lifecycle hook to clean up resources |
||||
|
* Revokes object URL when component is unmounted (web platform only) |
||||
|
*/ |
||||
|
beforeUnmount() { |
||||
|
if (this.downloadUrl && this.platformCapabilities.hasFileDownload) { |
||||
|
URL.revokeObjectURL(this.downloadUrl); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Exports the database to a JSON file |
||||
|
* Uses platform-specific methods for saving the exported data |
||||
|
* Shows success/error notifications to user |
||||
|
* |
||||
|
* @throws {Error} If export fails |
||||
|
* @emits {Notification} Success or error notification |
||||
|
*/ |
||||
|
public async exportDatabase() { |
||||
|
try { |
||||
|
const blob = await db.export({ prettyJson: true }); |
||||
|
const fileName = `${db.name}-backup.json`; |
||||
|
|
||||
|
if (this.platformCapabilities.hasFileDownload) { |
||||
|
// Web platform: Use download link |
||||
|
this.downloadUrl = URL.createObjectURL(blob); |
||||
|
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement; |
||||
|
downloadAnchor.href = this.downloadUrl; |
||||
|
downloadAnchor.download = fileName; |
||||
|
downloadAnchor.click(); |
||||
|
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000); |
||||
|
} else if (this.platformCapabilities.hasFileSystem) { |
||||
|
// Native platform: Write to app directory |
||||
|
const content = await blob.text(); |
||||
|
await this.platformService.writeAndShareFile(fileName, content); |
||||
|
} |
||||
|
|
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Export Successful", |
||||
|
text: this.platformCapabilities.hasFileDownload |
||||
|
? "See your downloads directory for the backup. It is in the Dexie format." |
||||
|
: "Please choose a location to save your backup file.", |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} catch (error) { |
||||
|
logger.error("Export Error:", error); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Export Error", |
||||
|
text: "There was an error exporting the data.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Computes class names for the initial download button |
||||
|
* @returns Object with 'hidden' class when download is in progress (web platform only) |
||||
|
*/ |
||||
|
public computedStartDownloadLinkClassNames() { |
||||
|
return { |
||||
|
hidden: this.downloadUrl && this.platformCapabilities.hasFileDownload, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Computes class names for the secondary download link |
||||
|
* @returns Object with 'hidden' class when no download is available or not on web platform |
||||
|
*/ |
||||
|
public computedDownloadLinkClassNames() { |
||||
|
return { |
||||
|
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload, |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,101 @@ |
|||||
|
/** |
||||
|
* Represents the result of an image capture or selection operation. |
||||
|
* Contains both the image data as a Blob and the associated filename. |
||||
|
*/ |
||||
|
export interface ImageResult { |
||||
|
/** The image data as a Blob object */ |
||||
|
blob: Blob; |
||||
|
/** The filename associated with the image */ |
||||
|
fileName: string; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Platform capabilities interface defining what features are available |
||||
|
* on the current platform implementation |
||||
|
*/ |
||||
|
export interface PlatformCapabilities { |
||||
|
/** Whether the platform supports native file system access */ |
||||
|
hasFileSystem: boolean; |
||||
|
/** Whether the platform supports native camera access */ |
||||
|
hasCamera: boolean; |
||||
|
/** Whether the platform is a mobile device */ |
||||
|
isMobile: boolean; |
||||
|
/** Whether the platform is iOS specifically */ |
||||
|
isIOS: boolean; |
||||
|
/** Whether the platform supports native file download */ |
||||
|
hasFileDownload: boolean; |
||||
|
/** Whether the platform requires special file handling instructions */ |
||||
|
needsFileHandlingInstructions: boolean; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Platform-agnostic interface for handling platform-specific operations. |
||||
|
* Provides a common API for file system operations, camera interactions, |
||||
|
* and platform detection across different platforms (web, mobile, desktop). |
||||
|
*/ |
||||
|
export interface PlatformService { |
||||
|
// Platform capabilities
|
||||
|
/** |
||||
|
* Gets the current platform's capabilities |
||||
|
* @returns Object describing what features are available on this platform |
||||
|
*/ |
||||
|
getCapabilities(): PlatformCapabilities; |
||||
|
|
||||
|
// File system operations
|
||||
|
/** |
||||
|
* Reads the contents of a file at the specified path. |
||||
|
* @param path - The path to the file to read |
||||
|
* @returns Promise resolving to the file contents as a string |
||||
|
*/ |
||||
|
readFile(path: string): Promise<string>; |
||||
|
|
||||
|
/** |
||||
|
* Writes content to a file at the specified path. |
||||
|
* @param path - The path where the file should be written |
||||
|
* @param content - The content to write to the file |
||||
|
* @returns Promise that resolves when the write is complete |
||||
|
*/ |
||||
|
writeFile(path: string, content: string): Promise<void>; |
||||
|
|
||||
|
/** |
||||
|
* Writes content to a file at the specified path and shares it. |
||||
|
* @param fileName - The filename of the file to write |
||||
|
* @param content - The content to write to the file |
||||
|
* @returns Promise that resolves when the write is complete |
||||
|
*/ |
||||
|
writeAndShareFile(fileName: string, content: string): Promise<void>; |
||||
|
|
||||
|
/** |
||||
|
* Deletes a file at the specified path. |
||||
|
* @param path - The path to the file to delete |
||||
|
* @returns Promise that resolves when the deletion is complete |
||||
|
*/ |
||||
|
deleteFile(path: string): Promise<void>; |
||||
|
|
||||
|
/** |
||||
|
* Lists all files in the specified directory. |
||||
|
* @param directory - The directory path to list |
||||
|
* @returns Promise resolving to an array of filenames |
||||
|
*/ |
||||
|
listFiles(directory: string): Promise<string[]>; |
||||
|
|
||||
|
// Camera operations
|
||||
|
/** |
||||
|
* Activates the device camera to take a picture. |
||||
|
* @returns Promise resolving to the captured image result |
||||
|
*/ |
||||
|
takePicture(): Promise<ImageResult>; |
||||
|
|
||||
|
/** |
||||
|
* Opens a file picker to select an existing image. |
||||
|
* @returns Promise resolving to the selected image result |
||||
|
*/ |
||||
|
pickImage(): Promise<ImageResult>; |
||||
|
|
||||
|
/** |
||||
|
* Handles deep link URLs for the application. |
||||
|
* @param url - The deep link URL to handle |
||||
|
* @returns Promise that resolves when the deep link has been handled |
||||
|
*/ |
||||
|
handleDeepLink(url: string): Promise<void>; |
||||
|
} |
@ -0,0 +1,58 @@ |
|||||
|
import { PlatformService } from "./PlatformService"; |
||||
|
import { WebPlatformService } from "./platforms/WebPlatformService"; |
||||
|
import { CapacitorPlatformService } from "./platforms/CapacitorPlatformService"; |
||||
|
import { ElectronPlatformService } from "./platforms/ElectronPlatformService"; |
||||
|
import { PyWebViewPlatformService } from "./platforms/PyWebViewPlatformService"; |
||||
|
|
||||
|
/** |
||||
|
* Factory class for creating platform-specific service implementations. |
||||
|
* Implements the Singleton pattern to ensure only one instance of PlatformService exists. |
||||
|
* |
||||
|
* The factory determines which platform implementation to use based on the VITE_PLATFORM |
||||
|
* environment variable. Supported platforms are: |
||||
|
* - capacitor: Mobile platform using Capacitor |
||||
|
* - electron: Desktop platform using Electron |
||||
|
* - pywebview: Python WebView implementation |
||||
|
* - web: Default web platform (fallback) |
||||
|
* |
||||
|
* @example |
||||
|
* ```typescript
|
||||
|
* const platformService = PlatformServiceFactory.getInstance(); |
||||
|
* await platformService.takePicture(); |
||||
|
* ``` |
||||
|
*/ |
||||
|
export class PlatformServiceFactory { |
||||
|
private static instance: PlatformService | null = null; |
||||
|
|
||||
|
/** |
||||
|
* Gets or creates the singleton instance of PlatformService. |
||||
|
* Creates the appropriate platform-specific implementation based on environment. |
||||
|
* |
||||
|
* @returns {PlatformService} The singleton instance of PlatformService |
||||
|
*/ |
||||
|
public static getInstance(): PlatformService { |
||||
|
if (PlatformServiceFactory.instance) { |
||||
|
return PlatformServiceFactory.instance; |
||||
|
} |
||||
|
|
||||
|
const platform = process.env.VITE_PLATFORM || "web"; |
||||
|
|
||||
|
switch (platform) { |
||||
|
case "capacitor": |
||||
|
PlatformServiceFactory.instance = new CapacitorPlatformService(); |
||||
|
break; |
||||
|
case "electron": |
||||
|
PlatformServiceFactory.instance = new ElectronPlatformService(); |
||||
|
break; |
||||
|
case "pywebview": |
||||
|
PlatformServiceFactory.instance = new PyWebViewPlatformService(); |
||||
|
break; |
||||
|
case "web": |
||||
|
default: |
||||
|
PlatformServiceFactory.instance = new WebPlatformService(); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
return PlatformServiceFactory.instance; |
||||
|
} |
||||
|
} |
@ -0,0 +1,473 @@ |
|||||
|
import { |
||||
|
ImageResult, |
||||
|
PlatformService, |
||||
|
PlatformCapabilities, |
||||
|
} from "../PlatformService"; |
||||
|
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"; |
||||
|
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera"; |
||||
|
import { Share } from "@capacitor/share"; |
||||
|
import { logger } from "../../utils/logger"; |
||||
|
|
||||
|
/** |
||||
|
* Platform service implementation for Capacitor (mobile) platform. |
||||
|
* Provides native mobile functionality through Capacitor plugins for: |
||||
|
* - File system operations |
||||
|
* - Camera and image picker |
||||
|
* - Platform-specific features |
||||
|
*/ |
||||
|
export class CapacitorPlatformService implements PlatformService { |
||||
|
/** |
||||
|
* Gets the capabilities of the Capacitor platform |
||||
|
* @returns Platform capabilities object |
||||
|
*/ |
||||
|
getCapabilities(): PlatformCapabilities { |
||||
|
return { |
||||
|
hasFileSystem: true, |
||||
|
hasCamera: true, |
||||
|
isMobile: true, |
||||
|
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), |
||||
|
hasFileDownload: false, |
||||
|
needsFileHandlingInstructions: true, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Checks and requests storage permissions if needed |
||||
|
* @returns Promise that resolves when permissions are granted |
||||
|
* @throws Error if permissions are denied |
||||
|
*/ |
||||
|
private async checkStoragePermissions(): Promise<void> { |
||||
|
try { |
||||
|
const logData = { |
||||
|
platform: this.getCapabilities().isIOS ? "iOS" : "Android", |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.log( |
||||
|
"Checking storage permissions", |
||||
|
JSON.stringify(logData, null, 2), |
||||
|
); |
||||
|
|
||||
|
if (this.getCapabilities().isIOS) { |
||||
|
// iOS uses different permission model
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Try to access a test directory to check permissions
|
||||
|
try { |
||||
|
await Filesystem.stat({ |
||||
|
path: "/storage/emulated/0/Download", |
||||
|
directory: Directory.Documents, |
||||
|
}); |
||||
|
logger.log( |
||||
|
"Storage permissions already granted", |
||||
|
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), |
||||
|
); |
||||
|
return; |
||||
|
} catch (error: unknown) { |
||||
|
const err = error as Error; |
||||
|
const errorLogData = { |
||||
|
error: { |
||||
|
message: err.message, |
||||
|
name: err.name, |
||||
|
stack: err.stack, |
||||
|
}, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
|
||||
|
// "File does not exist" is expected and not a permission error
|
||||
|
if (err.message === "File does not exist") { |
||||
|
logger.log( |
||||
|
"Directory does not exist (expected), proceeding with write", |
||||
|
JSON.stringify(errorLogData, null, 2), |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Check for actual permission errors
|
||||
|
if ( |
||||
|
err.message.includes("permission") || |
||||
|
err.message.includes("access") |
||||
|
) { |
||||
|
logger.log( |
||||
|
"Permission check failed, requesting permissions", |
||||
|
JSON.stringify(errorLogData, null, 2), |
||||
|
); |
||||
|
|
||||
|
// The Filesystem plugin will automatically request permissions when needed
|
||||
|
// We just need to try the operation again
|
||||
|
try { |
||||
|
await Filesystem.stat({ |
||||
|
path: "/storage/emulated/0/Download", |
||||
|
directory: Directory.Documents, |
||||
|
}); |
||||
|
logger.log( |
||||
|
"Storage permissions granted after request", |
||||
|
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), |
||||
|
); |
||||
|
return; |
||||
|
} catch (retryError: unknown) { |
||||
|
const retryErr = retryError as Error; |
||||
|
throw new Error( |
||||
|
`Failed to obtain storage permissions: ${retryErr.message}`, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// For any other error, log it but don't treat as permission error
|
||||
|
logger.log( |
||||
|
"Unexpected error during permission check", |
||||
|
JSON.stringify(errorLogData, null, 2), |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
} catch (error: unknown) { |
||||
|
const err = error as Error; |
||||
|
const errorLogData = { |
||||
|
error: { |
||||
|
message: err.message, |
||||
|
name: err.name, |
||||
|
stack: err.stack, |
||||
|
}, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.error( |
||||
|
"Error checking/requesting permissions", |
||||
|
JSON.stringify(errorLogData, null, 2), |
||||
|
); |
||||
|
throw new Error(`Failed to obtain storage permissions: ${err.message}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Reads a file from the app's data directory. |
||||
|
* @param path - Relative path to the file in the app's data directory |
||||
|
* @returns Promise resolving to the file contents as string |
||||
|
* @throws Error if file cannot be read or doesn't exist |
||||
|
*/ |
||||
|
async readFile(path: string): Promise<string> { |
||||
|
const file = await Filesystem.readFile({ |
||||
|
path, |
||||
|
directory: Directory.Data, |
||||
|
}); |
||||
|
if (file.data instanceof Blob) { |
||||
|
return await file.data.text(); |
||||
|
} |
||||
|
return file.data; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Writes content to a file in the app's safe storage and offers sharing. |
||||
|
* |
||||
|
* Platform-specific behavior: |
||||
|
* - Saves to app's Documents directory |
||||
|
* - Offers sharing functionality to move file elsewhere |
||||
|
* |
||||
|
* The method handles: |
||||
|
* 1. Writing to app-safe storage |
||||
|
* 2. Sharing the file with user's preferred app |
||||
|
* 3. Error handling and logging |
||||
|
* |
||||
|
* @param fileName - The name of the file to create (e.g. "backup.json") |
||||
|
* @param content - The content to write to the file |
||||
|
* |
||||
|
* @throws Error if: |
||||
|
* - File writing fails |
||||
|
* - Sharing fails |
||||
|
* |
||||
|
* @example |
||||
|
* ```typescript
|
||||
|
* // Save and share a JSON file
|
||||
|
* await platformService.writeFile( |
||||
|
* "backup.json", |
||||
|
* JSON.stringify(data) |
||||
|
* ); |
||||
|
* ``` |
||||
|
*/ |
||||
|
async writeFile(fileName: string, content: string): Promise<void> { |
||||
|
try { |
||||
|
const logData = { |
||||
|
targetFileName: fileName, |
||||
|
contentLength: content.length, |
||||
|
platform: this.getCapabilities().isIOS ? "iOS" : "Android", |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.log( |
||||
|
"Starting writeFile operation", |
||||
|
JSON.stringify(logData, null, 2), |
||||
|
); |
||||
|
|
||||
|
// For Android, we need to handle content URIs differently
|
||||
|
if (this.getCapabilities().isIOS) { |
||||
|
// Write to app's Documents directory for iOS
|
||||
|
const writeResult = await Filesystem.writeFile({ |
||||
|
path: fileName, |
||||
|
data: content, |
||||
|
directory: Directory.Data, |
||||
|
encoding: Encoding.UTF8, |
||||
|
}); |
||||
|
|
||||
|
const writeSuccessLogData = { |
||||
|
path: writeResult.uri, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.log( |
||||
|
"File write successful", |
||||
|
JSON.stringify(writeSuccessLogData, null, 2), |
||||
|
); |
||||
|
|
||||
|
// Offer to share the file
|
||||
|
try { |
||||
|
await Share.share({ |
||||
|
title: "TimeSafari Backup", |
||||
|
text: "Here is your TimeSafari backup file.", |
||||
|
url: writeResult.uri, |
||||
|
dialogTitle: "Share your backup", |
||||
|
}); |
||||
|
|
||||
|
logger.log( |
||||
|
"Share dialog shown", |
||||
|
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), |
||||
|
); |
||||
|
} catch (shareError) { |
||||
|
// Log share error but don't fail the operation
|
||||
|
logger.error( |
||||
|
"Share dialog failed", |
||||
|
JSON.stringify( |
||||
|
{ |
||||
|
error: shareError, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}, |
||||
|
null, |
||||
|
2, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} else { |
||||
|
// For Android, first write to app's Documents directory
|
||||
|
const writeResult = await Filesystem.writeFile({ |
||||
|
path: fileName, |
||||
|
data: content, |
||||
|
directory: Directory.Data, |
||||
|
encoding: Encoding.UTF8, |
||||
|
}); |
||||
|
|
||||
|
const writeSuccessLogData = { |
||||
|
path: writeResult.uri, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.log( |
||||
|
"File write successful to app storage", |
||||
|
JSON.stringify(writeSuccessLogData, null, 2), |
||||
|
); |
||||
|
|
||||
|
// Then share the file to let user choose where to save it
|
||||
|
try { |
||||
|
await Share.share({ |
||||
|
title: "TimeSafari Backup", |
||||
|
text: "Here is your TimeSafari backup file.", |
||||
|
url: writeResult.uri, |
||||
|
dialogTitle: "Save your backup", |
||||
|
}); |
||||
|
|
||||
|
logger.log( |
||||
|
"Share dialog shown for Android", |
||||
|
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), |
||||
|
); |
||||
|
} catch (shareError) { |
||||
|
// Log share error but don't fail the operation
|
||||
|
logger.error( |
||||
|
"Share dialog failed for Android", |
||||
|
JSON.stringify( |
||||
|
{ |
||||
|
error: shareError, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}, |
||||
|
null, |
||||
|
2, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} catch (error: unknown) { |
||||
|
const err = error as Error; |
||||
|
const finalErrorLogData = { |
||||
|
error: { |
||||
|
message: err.message, |
||||
|
name: err.name, |
||||
|
stack: err.stack, |
||||
|
}, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.error( |
||||
|
"Error in writeFile operation:", |
||||
|
JSON.stringify(finalErrorLogData, null, 2), |
||||
|
); |
||||
|
throw new Error(`Failed to save file: ${err.message}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Writes content to a file in the device's app-private storage. |
||||
|
* Then shares the file using the system share dialog. |
||||
|
* |
||||
|
* Works on both Android and iOS without needing external storage permissions. |
||||
|
* |
||||
|
* @param fileName - The name of the file to create (e.g. "backup.json") |
||||
|
* @param content - The content to write to the file |
||||
|
*/ |
||||
|
async writeAndShareFile(fileName: string, content: string): Promise<void> { |
||||
|
const timestamp = new Date().toISOString(); |
||||
|
const logData = { |
||||
|
action: 'writeAndShareFile', |
||||
|
fileName, |
||||
|
contentLength: content.length, |
||||
|
timestamp, |
||||
|
}; |
||||
|
logger.log('[CapacitorPlatformService]', JSON.stringify(logData, null, 2)); |
||||
|
|
||||
|
try { |
||||
|
const { uri } = await Filesystem.writeFile({ |
||||
|
path: fileName, |
||||
|
data: content, |
||||
|
directory: Directory.Data, |
||||
|
encoding: Encoding.UTF8, |
||||
|
recursive: true, |
||||
|
}); |
||||
|
|
||||
|
logger.log('[CapacitorPlatformService] File write successful:', { uri, timestamp: new Date().toISOString() }); |
||||
|
|
||||
|
await Share.share({ |
||||
|
title: 'TimeSafari Backup', |
||||
|
text: 'Here is your backup file.', |
||||
|
url: uri, |
||||
|
dialogTitle: 'Share your backup file', |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
const err = error as Error; |
||||
|
const errLog = { |
||||
|
message: err.message, |
||||
|
stack: err.stack, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.error('[CapacitorPlatformService] Error writing or sharing file:', JSON.stringify(errLog, null, 2)); |
||||
|
throw new Error(`Failed to write or share file: ${err.message}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Deletes a file from the app's data directory. |
||||
|
* @param path - Relative path to the file to delete |
||||
|
* @throws Error if deletion fails or file doesn't exist |
||||
|
*/ |
||||
|
async deleteFile(path: string): Promise<void> { |
||||
|
await Filesystem.deleteFile({ |
||||
|
path, |
||||
|
directory: Directory.Data, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Lists files in the specified directory within app's data directory. |
||||
|
* @param directory - Relative path to the directory to list |
||||
|
* @returns Promise resolving to array of filenames |
||||
|
* @throws Error if directory cannot be read or doesn't exist |
||||
|
*/ |
||||
|
async listFiles(directory: string): Promise<string[]> { |
||||
|
const result = await Filesystem.readdir({ |
||||
|
path: directory, |
||||
|
directory: Directory.Data, |
||||
|
}); |
||||
|
return result.files.map((file) => |
||||
|
typeof file === "string" ? file : file.name, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Opens the device camera to take a picture. |
||||
|
* Configures camera for high quality images with editing enabled. |
||||
|
* @returns Promise resolving to the captured image data |
||||
|
* @throws Error if camera access fails or user cancels |
||||
|
*/ |
||||
|
async takePicture(): Promise<ImageResult> { |
||||
|
try { |
||||
|
const image = await Camera.getPhoto({ |
||||
|
quality: 90, |
||||
|
allowEditing: true, |
||||
|
resultType: CameraResultType.Base64, |
||||
|
source: CameraSource.Camera, |
||||
|
}); |
||||
|
|
||||
|
const blob = await this.processImageData(image.base64String); |
||||
|
return { |
||||
|
blob, |
||||
|
fileName: `photo_${Date.now()}.${image.format || "jpg"}`, |
||||
|
}; |
||||
|
} catch (error) { |
||||
|
logger.error("Error taking picture with Capacitor:", error); |
||||
|
throw new Error("Failed to take picture"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Opens the device photo gallery to pick an existing image. |
||||
|
* Configures picker for high quality images with editing enabled. |
||||
|
* @returns Promise resolving to the selected image data |
||||
|
* @throws Error if gallery access fails or user cancels |
||||
|
*/ |
||||
|
async pickImage(): Promise<ImageResult> { |
||||
|
try { |
||||
|
const image = await Camera.getPhoto({ |
||||
|
quality: 90, |
||||
|
allowEditing: true, |
||||
|
resultType: CameraResultType.Base64, |
||||
|
source: CameraSource.Photos, |
||||
|
}); |
||||
|
|
||||
|
const blob = await this.processImageData(image.base64String); |
||||
|
return { |
||||
|
blob, |
||||
|
fileName: `photo_${Date.now()}.${image.format || "jpg"}`, |
||||
|
}; |
||||
|
} catch (error) { |
||||
|
logger.error("Error picking image with Capacitor:", error); |
||||
|
throw new Error("Failed to pick image"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Converts base64 image data to a Blob. |
||||
|
* @param base64String - Base64 encoded image data |
||||
|
* @returns Promise resolving to image Blob |
||||
|
* @throws Error if conversion fails |
||||
|
*/ |
||||
|
private async processImageData(base64String?: string): Promise<Blob> { |
||||
|
if (!base64String) { |
||||
|
throw new Error("No image data received"); |
||||
|
} |
||||
|
|
||||
|
// Convert base64 to blob
|
||||
|
const byteCharacters = atob(base64String); |
||||
|
const byteArrays = []; |
||||
|
for (let offset = 0; offset < byteCharacters.length; offset += 512) { |
||||
|
const slice = byteCharacters.slice(offset, offset + 512); |
||||
|
const byteNumbers = new Array(slice.length); |
||||
|
for (let i = 0; i < slice.length; i++) { |
||||
|
byteNumbers[i] = slice.charCodeAt(i); |
||||
|
} |
||||
|
const byteArray = new Uint8Array(byteNumbers); |
||||
|
byteArrays.push(byteArray); |
||||
|
} |
||||
|
return new Blob(byteArrays, { type: "image/jpeg" }); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Handles deep link URLs for the application. |
||||
|
* Note: Capacitor handles deep links automatically. |
||||
|
* @param _url - The deep link URL (unused) |
||||
|
*/ |
||||
|
async handleDeepLink(_url: string): Promise<void> { |
||||
|
// Capacitor handles deep links automatically
|
||||
|
// This is just a placeholder for the interface
|
||||
|
return Promise.resolve(); |
||||
|
} |
||||
|
} |
@ -0,0 +1,111 @@ |
|||||
|
import { |
||||
|
ImageResult, |
||||
|
PlatformService, |
||||
|
PlatformCapabilities, |
||||
|
} from "../PlatformService"; |
||||
|
import { logger } from "../../utils/logger"; |
||||
|
|
||||
|
/** |
||||
|
* Platform service implementation for Electron (desktop) platform. |
||||
|
* Note: This is a placeholder implementation with most methods currently unimplemented. |
||||
|
* Implements the PlatformService interface but throws "Not implemented" errors for most operations. |
||||
|
* |
||||
|
* @remarks |
||||
|
* This service is intended for desktop application functionality through Electron. |
||||
|
* Future implementations should provide: |
||||
|
* - Native file system access |
||||
|
* - Desktop camera integration |
||||
|
* - System-level features |
||||
|
*/ |
||||
|
export class ElectronPlatformService implements PlatformService { |
||||
|
/** |
||||
|
* Gets the capabilities of the Electron platform |
||||
|
* @returns Platform capabilities object |
||||
|
*/ |
||||
|
getCapabilities(): PlatformCapabilities { |
||||
|
return { |
||||
|
hasFileSystem: false, // Not implemented yet
|
||||
|
hasCamera: false, // Not implemented yet
|
||||
|
isMobile: false, |
||||
|
isIOS: false, |
||||
|
hasFileDownload: false, // Not implemented yet
|
||||
|
needsFileHandlingInstructions: false, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Reads a file from the filesystem. |
||||
|
* @param _path - Path to the file to read |
||||
|
* @returns Promise that should resolve to file contents |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file reading using Electron's file system API |
||||
|
*/ |
||||
|
async readFile(_path: string): Promise<string> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Writes content to a file. |
||||
|
* @param _path - Path where to write the file |
||||
|
* @param _content - Content to write to the file |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file writing using Electron's file system API |
||||
|
*/ |
||||
|
async writeFile(_path: string, _content: string): Promise<void> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Deletes a file from the filesystem. |
||||
|
* @param _path - Path to the file to delete |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file deletion using Electron's file system API |
||||
|
*/ |
||||
|
async deleteFile(_path: string): Promise<void> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Lists files in the specified directory. |
||||
|
* @param _directory - Path to the directory to list |
||||
|
* @returns Promise that should resolve to array of filenames |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement directory listing using Electron's file system API |
||||
|
*/ |
||||
|
async listFiles(_directory: string): Promise<string[]> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Should open system camera to take a picture. |
||||
|
* @returns Promise that should resolve to captured image data |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement camera access using Electron's media APIs |
||||
|
*/ |
||||
|
async takePicture(): Promise<ImageResult> { |
||||
|
logger.error("takePicture not implemented in Electron platform"); |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Should open system file picker for selecting an image. |
||||
|
* @returns Promise that should resolve to selected image data |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file picker using Electron's dialog API |
||||
|
*/ |
||||
|
async pickImage(): Promise<ImageResult> { |
||||
|
logger.error("pickImage not implemented in Electron platform"); |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Should handle deep link URLs for the desktop application. |
||||
|
* @param _url - The deep link URL to handle |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement deep link handling using Electron's protocol handler |
||||
|
*/ |
||||
|
async handleDeepLink(_url: string): Promise<void> { |
||||
|
logger.error("handleDeepLink not implemented in Electron platform"); |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
} |
@ -0,0 +1,112 @@ |
|||||
|
import { |
||||
|
ImageResult, |
||||
|
PlatformService, |
||||
|
PlatformCapabilities, |
||||
|
} from "../PlatformService"; |
||||
|
import { logger } from "../../utils/logger"; |
||||
|
|
||||
|
/** |
||||
|
* Platform service implementation for PyWebView platform. |
||||
|
* Note: This is a placeholder implementation with most methods currently unimplemented. |
||||
|
* Implements the PlatformService interface but throws "Not implemented" errors for most operations. |
||||
|
* |
||||
|
* @remarks |
||||
|
* This service is intended for Python-based desktop applications using pywebview. |
||||
|
* Future implementations should provide: |
||||
|
* - Integration with Python backend file operations |
||||
|
* - System camera access through Python |
||||
|
* - Native system dialogs via pywebview |
||||
|
* - Python-JavaScript bridge functionality |
||||
|
*/ |
||||
|
export class PyWebViewPlatformService implements PlatformService { |
||||
|
/** |
||||
|
* Gets the capabilities of the PyWebView platform |
||||
|
* @returns Platform capabilities object |
||||
|
*/ |
||||
|
getCapabilities(): PlatformCapabilities { |
||||
|
return { |
||||
|
hasFileSystem: false, // Not implemented yet
|
||||
|
hasCamera: false, // Not implemented yet
|
||||
|
isMobile: false, |
||||
|
isIOS: false, |
||||
|
hasFileDownload: false, // Not implemented yet
|
||||
|
needsFileHandlingInstructions: false, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Reads a file using the Python backend. |
||||
|
* @param _path - Path to the file to read |
||||
|
* @returns Promise that should resolve to file contents |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file reading through pywebview's Python-JavaScript bridge |
||||
|
*/ |
||||
|
async readFile(_path: string): Promise<string> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Writes content to a file using the Python backend. |
||||
|
* @param _path - Path where to write the file |
||||
|
* @param _content - Content to write to the file |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file writing through pywebview's Python-JavaScript bridge |
||||
|
*/ |
||||
|
async writeFile(_path: string, _content: string): Promise<void> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Deletes a file using the Python backend. |
||||
|
* @param _path - Path to the file to delete |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file deletion through pywebview's Python-JavaScript bridge |
||||
|
*/ |
||||
|
async deleteFile(_path: string): Promise<void> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Lists files in the specified directory using the Python backend. |
||||
|
* @param _directory - Path to the directory to list |
||||
|
* @returns Promise that should resolve to array of filenames |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement directory listing through pywebview's Python-JavaScript bridge |
||||
|
*/ |
||||
|
async listFiles(_directory: string): Promise<string[]> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Should open system camera through Python backend. |
||||
|
* @returns Promise that should resolve to captured image data |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement camera access using Python's camera libraries |
||||
|
*/ |
||||
|
async takePicture(): Promise<ImageResult> { |
||||
|
logger.error("takePicture not implemented in PyWebView platform"); |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Should open system file picker through pywebview. |
||||
|
* @returns Promise that should resolve to selected image data |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file picker using pywebview's file dialog API |
||||
|
*/ |
||||
|
async pickImage(): Promise<ImageResult> { |
||||
|
logger.error("pickImage not implemented in PyWebView platform"); |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Should handle deep link URLs through the Python backend. |
||||
|
* @param _url - The deep link URL to handle |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement deep link handling using Python's URL handling capabilities |
||||
|
*/ |
||||
|
async handleDeepLink(_url: string): Promise<void> { |
||||
|
logger.error("handleDeepLink not implemented in PyWebView platform"); |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
} |
@ -0,0 +1,231 @@ |
|||||
|
import { |
||||
|
ImageResult, |
||||
|
PlatformService, |
||||
|
PlatformCapabilities, |
||||
|
} from "../PlatformService"; |
||||
|
import { logger } from "../../utils/logger"; |
||||
|
|
||||
|
/** |
||||
|
* Platform service implementation for web browser platform. |
||||
|
* Implements the PlatformService interface with web-specific functionality. |
||||
|
* |
||||
|
* @remarks |
||||
|
* This service provides web-based implementations for: |
||||
|
* - Image capture using the browser's file input |
||||
|
* - Image selection from local filesystem |
||||
|
* - Image processing and conversion |
||||
|
* |
||||
|
* Note: File system operations are not available in the web platform |
||||
|
* due to browser security restrictions. These methods throw appropriate errors. |
||||
|
*/ |
||||
|
export class WebPlatformService implements PlatformService { |
||||
|
/** |
||||
|
* Gets the capabilities of the web platform |
||||
|
* @returns Platform capabilities object |
||||
|
*/ |
||||
|
getCapabilities(): PlatformCapabilities { |
||||
|
return { |
||||
|
hasFileSystem: false, |
||||
|
hasCamera: true, // Through file input with capture
|
||||
|
isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent), |
||||
|
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), |
||||
|
hasFileDownload: true, |
||||
|
needsFileHandlingInstructions: false, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Not supported in web platform. |
||||
|
* @param _path - Unused path parameter |
||||
|
* @throws Error indicating file system access is not available |
||||
|
*/ |
||||
|
async readFile(_path: string): Promise<string> { |
||||
|
throw new Error("File system access not available in web platform"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Not supported in web platform. |
||||
|
* @param _path - Unused path parameter |
||||
|
* @param _content - Unused content parameter |
||||
|
* @throws Error indicating file system access is not available |
||||
|
*/ |
||||
|
async writeFile(_path: string, _content: string): Promise<void> { |
||||
|
throw new Error("File system access not available in web platform"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Not supported in web platform. |
||||
|
* @param _path - Unused path parameter |
||||
|
* @throws Error indicating file system access is not available |
||||
|
*/ |
||||
|
async deleteFile(_path: string): Promise<void> { |
||||
|
throw new Error("File system access not available in web platform"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Not supported in web platform. |
||||
|
* @param _directory - Unused directory parameter |
||||
|
* @throws Error indicating file system access is not available |
||||
|
*/ |
||||
|
async listFiles(_directory: string): Promise<string[]> { |
||||
|
throw new Error("File system access not available in web platform"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Opens a file input dialog configured for camera capture. |
||||
|
* Creates a temporary file input element to access the device camera. |
||||
|
* |
||||
|
* @returns Promise resolving to the captured image data |
||||
|
* @throws Error if image capture fails or no image is selected |
||||
|
* |
||||
|
* @remarks |
||||
|
* Uses the 'capture' attribute to prefer the device camera. |
||||
|
* Falls back to file selection if camera is not available. |
||||
|
* Processes the captured image to ensure consistent format. |
||||
|
*/ |
||||
|
async takePicture(): Promise<ImageResult> { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const input = document.createElement("input"); |
||||
|
input.type = "file"; |
||||
|
input.accept = "image/*"; |
||||
|
input.capture = "environment"; |
||||
|
|
||||
|
input.onchange = async (e) => { |
||||
|
const file = (e.target as HTMLInputElement).files?.[0]; |
||||
|
if (file) { |
||||
|
try { |
||||
|
const blob = await this.processImageFile(file); |
||||
|
resolve({ |
||||
|
blob, |
||||
|
fileName: file.name || "photo.jpg", |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
logger.error("Error processing camera image:", error); |
||||
|
reject(new Error("Failed to process camera image")); |
||||
|
} |
||||
|
} else { |
||||
|
reject(new Error("No image captured")); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
input.click(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Opens a file input dialog for selecting an image file. |
||||
|
* Creates a temporary file input element to access local files. |
||||
|
* |
||||
|
* @returns Promise resolving to the selected image data |
||||
|
* @throws Error if image processing fails or no image is selected |
||||
|
* |
||||
|
* @remarks |
||||
|
* Allows selection of any image file type. |
||||
|
* Processes the selected image to ensure consistent format. |
||||
|
*/ |
||||
|
async pickImage(): Promise<ImageResult> { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const input = document.createElement("input"); |
||||
|
input.type = "file"; |
||||
|
input.accept = "image/*"; |
||||
|
|
||||
|
input.onchange = async (e) => { |
||||
|
const file = (e.target as HTMLInputElement).files?.[0]; |
||||
|
if (file) { |
||||
|
try { |
||||
|
const blob = await this.processImageFile(file); |
||||
|
resolve({ |
||||
|
blob, |
||||
|
fileName: file.name || "photo.jpg", |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
logger.error("Error processing picked image:", error); |
||||
|
reject(new Error("Failed to process picked image")); |
||||
|
} |
||||
|
} else { |
||||
|
reject(new Error("No image selected")); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
input.click(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Processes an image file to ensure consistent format. |
||||
|
* Converts the file to a data URL and then to a Blob. |
||||
|
* |
||||
|
* @param file - The image File object to process |
||||
|
* @returns Promise resolving to processed image Blob |
||||
|
* @throws Error if file reading or conversion fails |
||||
|
* |
||||
|
* @remarks |
||||
|
* This method ensures consistent image format across different |
||||
|
* input sources by converting through data URL to Blob. |
||||
|
*/ |
||||
|
private async processImageFile(file: File): Promise<Blob> { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const reader = new FileReader(); |
||||
|
reader.onload = (event) => { |
||||
|
const dataUrl = event.target?.result as string; |
||||
|
// Convert to blob to ensure consistent format
|
||||
|
fetch(dataUrl) |
||||
|
.then((res) => res.blob()) |
||||
|
.then((blob) => resolve(blob)) |
||||
|
.catch((error) => { |
||||
|
logger.error("Error converting data URL to blob:", error); |
||||
|
reject(error); |
||||
|
}); |
||||
|
}; |
||||
|
reader.onerror = (error) => { |
||||
|
logger.error("Error reading file:", error); |
||||
|
reject(error); |
||||
|
}; |
||||
|
reader.readAsDataURL(file); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Checks if running on Capacitor platform. |
||||
|
* @returns false, as this is not Capacitor |
||||
|
*/ |
||||
|
isCapacitor(): boolean { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Checks if running on Electron platform. |
||||
|
* @returns false, as this is not Electron |
||||
|
*/ |
||||
|
isElectron(): boolean { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Checks if running on PyWebView platform. |
||||
|
* @returns false, as this is not PyWebView |
||||
|
*/ |
||||
|
isPyWebView(): boolean { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Checks if running on web platform. |
||||
|
* @returns true, as this is the web implementation |
||||
|
*/ |
||||
|
isWeb(): boolean { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Handles deep link URLs in the web platform. |
||||
|
* Deep links are handled through URL parameters in the web environment. |
||||
|
* |
||||
|
* @param _url - The deep link URL to handle (unused in web implementation) |
||||
|
* @returns Promise that resolves immediately as web handles URLs naturally |
||||
|
*/ |
||||
|
async handleDeepLink(_url: string): Promise<void> { |
||||
|
// Web platform can handle deep links through URL parameters
|
||||
|
return Promise.resolve(); |
||||
|
} |
||||
|
} |
@ -1,35 +1,31 @@ |
|||||
{ |
{ |
||||
"compilerOptions": { |
"compilerOptions": { |
||||
"target": "ES2020", // Latest ECMAScript features that are widely supported by modern browsers |
"target": "ES2020", // Latest ECMAScript features that are widely supported by modern browsers |
||||
|
"useDefineForClassFields": true, |
||||
"module": "ESNext", // Use ES modules |
"module": "ESNext", // Use ES modules |
||||
"strict": true, // Enable all strict type checking options |
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
||||
|
"skipLibCheck": true, |
||||
|
"moduleResolution": "bundler", |
||||
|
"allowImportingTsExtensions": true, |
||||
|
"resolveJsonModule": true, |
||||
|
"isolatedModules": true, |
||||
|
"noEmit": true, |
||||
"jsx": "preserve", // Preserves JSX to be transformed by Babel or another transpiler |
"jsx": "preserve", // Preserves JSX to be transformed by Babel or another transpiler |
||||
"moduleResolution": "node", // Use Node.js style module resolution |
"strict": true, // Enable all strict type checking options |
||||
|
"noUnusedLocals": true, |
||||
|
"noUnusedParameters": true, |
||||
|
"noFallthroughCasesInSwitch": true, |
||||
|
"baseUrl": ".", |
||||
"experimentalDecorators": true, |
"experimentalDecorators": true, |
||||
"esModuleInterop": true, // Enables compatibility with CommonJS modules for default imports |
|
||||
"allowSyntheticDefaultImports": true, // Allow default imports from modules with no default export |
|
||||
"forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file |
|
||||
"useDefineForClassFields": true, |
|
||||
"sourceMap": true, |
|
||||
"baseUrl": "./src", // Base directory to resolve non-relative module names |
|
||||
"paths": { |
"paths": { |
||||
"@/components/*": ["components/*"], |
"@/*": ["src/*"] |
||||
"@/views/*": ["views/*"], |
} |
||||
"@/db/*": ["db/*"], |
|
||||
"@/libs/*": ["libs/*"], |
|
||||
"@/constants/*": ["constants/*"], |
|
||||
"@/store/*": ["store/*"] |
|
||||
}, |
|
||||
"lib": ["ES2020", "dom", "dom.iterable"], // Include typings for ES2020 and DOM APIs |
|
||||
}, |
}, |
||||
"include": [ |
"include": [ |
||||
"src/**/*.ts", |
"src/**/*.ts", |
||||
|
"src/**/*.d.ts", |
||||
"src/**/*.tsx", |
"src/**/*.tsx", |
||||
"src/**/*.vue", |
"src/**/*.vue" |
||||
"test-playwright/**/*.ts", |
|
||||
"test-playwright/**/*.tsx" |
|
||||
], |
], |
||||
"exclude": [ |
"references": [{ "path": "./tsconfig.node.json" }] |
||||
"node_modules" |
|
||||
] |
|
||||
} |
} |
||||
|
@ -0,0 +1,10 @@ |
|||||
|
{ |
||||
|
"compilerOptions": { |
||||
|
"composite": true, |
||||
|
"skipLibCheck": true, |
||||
|
"module": "ESNext", |
||||
|
"moduleResolution": "bundler", |
||||
|
"allowSyntheticDefaultImports": true |
||||
|
}, |
||||
|
"include": ["vite.config.*"] |
||||
|
} |