Compare commits

..

28 Commits

Author SHA1 Message Date
1d7bc5f728 include a file missed before 2025-05-25 09:50:45 -06:00
753d178fc3 try to insert wa-sqlite but fail with: WebAssembly.instantiate(): expected magic word 00 61 73 6d, found 3c 21 44 4f @+0) 2025-05-25 00:12:24 -06:00
Matt Raymer
30fb3aee8e docs: enhance secure storage implementation documentation
Add detailed platform implementations, usage examples, and error handling: - Add comprehensive platform-specific implementations for Web (Dexie) and Capacitor (SQLite) - Include detailed database initialization and security features - Add practical usage examples for account and settings management - Document error handling strategies and edge cases - Add concurrency management and data integrity checks - Include platform transition handling

This update provides a complete reference for implementing secure storage across different platforms while maintaining backward compatibility.
2025-05-24 23:37:09 -04:00
e254837951 tweak messages & commentary 2025-05-24 20:55:54 -06:00
8417cad2f3 add documentation for some new work: storage 2025-05-24 18:22:05 -06:00
0f56b659c1 adjust privacy notice with the name 2025-05-23 13:07:56 -06:00
a8bfcb720a adjust the messaging for setting one's name 2025-05-23 13:04:49 -06:00
c23e30c431 remove duplicate prompt to get registered 2025-05-23 12:59:57 -06:00
1129a13e20 add more error handling and messaging when there are bad DB errors 2025-05-23 12:35:16 -06:00
5b6c59c232 show an error if the import goes badly 2025-05-23 11:09:22 -06:00
295a2d9f63 don't export 0s for undefined values! 2025-05-23 11:06:37 -06:00
Jose Olarte III
6e14ccdbbc Fix: mirror camera view
- Always when using a desktop browser
- NEEDS TESTING: Conditionally in mobile
2025-05-23 19:21:20 +08:00
Jose Olarte III
d636b21744 Fix: limit image crop stage height
- Addresses issue when extra-tall portrait images are uploaded for cropping
2025-05-23 17:25:22 +08:00
37b7c4ed36 various instructions (and their timeouts) 2025-05-22 12:27:37 -06:00
Jose Olarte III
f7728aadf0 Revert iOS app ID 2025-05-22 22:36:16 +08:00
Jose Olarte III
ce34257ba1 De-coupled web and mobile QR scanner views
- Separate scanner views for web and mobile platforms: different libraries, similar layouts
- Mobile: QR code overlaid on top of full-screen camera view
- Mobile: added framing box + instruction text
- Mobile: increased debounce time to compensate for behavior of MLkit scanner
- Web: removed Capacitor-related code and platform-specific conditions
- Web: adjusted max-size of QR code and camera view to better fit newer iOS device screens
- Web + mobile: camera view remains active when a QR scan is triggered
2025-05-22 19:46:19 +08:00
Matt Raymer
190c972f57 Remove ContactScanView and rename ContactQRScanView to ContactQRScanFullView
- Deleted ContactScanView.vue and its route from the router.
- Renamed ContactQRScanView.vue to ContactQRScanFullView.vue.
- Updated all router paths, names, and references for consistency.
- Fixed related links and imports to use the new view/component name.
2025-05-21 05:17:25 -04:00
Jose Olarte III
831df4b253 Restored diagnostics repositioning 2025-05-21 16:39:21 +08:00
Matt Raymer
55176ed5db Remove ContactScanView and update QR scan view naming for consistency
- Deleted ContactScanView.vue and removed its route from the router
- Renamed ContactQRScanView.vue to ContactQRScanFullView.vue
- Updated all router paths, names, and references to use 'contact-qr-scan-full'
- Updated related router links in ContactQRScanShowView.vue for consistency
- Ensured all naming and routing is consolidated and matches the new view/component name
2025-05-21 04:16:58 -04:00
Matt Raymer
b491262bef Only request camera permissions on user gesture in ImageMethodDialog
- Removed automatic call to startCameraPreview() from mounted() lifecycle hook
- Camera preview (and permission prompt) now only starts in open(), triggered by user action
- Prevents unnecessary permission prompts on page load and improves UX
2025-05-21 03:48:57 -04:00
Jose Olarte III
a1c18458e7 Repositioned diagnostics over camera preview 2025-05-21 15:02:30 +08:00
Jose Olarte III
995af4e576 Restrict camera height in portrait mode 2025-05-21 14:49:29 +08:00
Matt Raymer
8ac728d488 Remove temporary alert() debug calls from ImageMethodDialog camera preview
- Cleaned up all alert() calls used for diagnosing camera access issues on mobile browsers
- Camera preview now starts without pop-up interruptions
- Retained logging and user notifications for error handling and diagnostics
2025-05-21 02:23:52 -04:00
Matt Raymer
913f11b66c Merge remote-tracking branch 'refs/remotes/origin/qrcode-reboot' into qrcode-reboot 2025-05-20 23:08:37 -04:00
Matt Raymer
79882715d8 fix: update Alpine version to 3.20 for stable package repositories
- Change base image from node:22-alpine to node:22-alpine3.20
- Resolves package installation issues with Alpine 3.21 repositories
- Ensures stable access to build dependencies (python3, gcc, etc.)

This change fixes the Docker build process by using a more stable
version of Alpine Linux that has reliable package repositories.
2025-05-20 23:07:49 -04:00
22978a1eda bump to build 18 version 0.4.7 to match the latest upload to ios 2025-05-20 20:27:38 -06:00
79b2218129 add a privacy-fixer project that may have fixed the GoogleToolboxForMac privacy manifext problem
https://github.com/crasowas/app_privacy_manifest_fixer
2025-05-20 20:24:21 -06:00
52685702c1 Merge pull request 'trent-tweaks' (#133) from trent-tweaks into qrcode-reboot
Reviewed-on: #133
2025-05-20 10:52:33 -04:00
45 changed files with 5128 additions and 312 deletions

View File

@@ -327,6 +327,7 @@ Prerequisites: macOS with Xcode installed
1. Build the web assets:
```bash
rm -rf dist
npm run build:web
npm run build:capacitor
```
@@ -346,7 +347,7 @@ Prerequisites: macOS with Xcode installed
npx capacitor-assets generate --ios
```
4. Bump the version to match Android
4. Bump the version to match Android:
```
cd ios/App
@@ -395,10 +396,6 @@ Prerequisites: Android Studio with SDK installed
rm -rf dist
npm run build:web
npm run build:capacitor
cd android
./gradlew clean
./gradlew assembleDebug
cd ..
```
2. Update Android project with latest build:
@@ -413,7 +410,7 @@ Prerequisites: Android Studio with SDK installed
npx capacitor-assets generate --android
```
4. Bump version to match iOS, in android/app/build.gradleq
4. Bump version to match iOS: android/app/build.gradle
5. Open the project in Android Studio:
@@ -429,7 +426,7 @@ Prerequisites: Android Studio with SDK installed
cd android
./gradlew clean
./gradlew build -Dlint.baselines.continue=true
cd ..
cd -
npx cap run android
```
@@ -453,9 +450,11 @@ Prerequisites: Android Studio with SDK installed
At play.google.com/console:
- Create new release, upload, hit Next.
- Save & send changes for review.
- Go to the Testing Track (eg. Closed).
- Click "Create new release".
- Upload the `aab` file.
- Hit "Next".
- Save, go to "Publishing Overview" as prompted, and click "Send changes for review".
## First-time Android Configuration for deep links

View File

@@ -1,15 +1,9 @@
# Build stage
FROM node:22-alpine AS builder
FROM node:22-alpine3.20 AS builder
# Install build dependencies
RUN apk add --no-cache \
python3 \
py3-pip \
py3-setuptools \
make \
g++ \
gcc
RUN apk add --no-cache bash git python3 py3-pip py3-setuptools make g++ gcc
# Set working directory
WORKDIR /app

View File

@@ -12,6 +12,8 @@ See [project.task.yaml](project.task.yaml) for current priorities.
Quick start:
* For setup, we recommend [pkgx](https://pkgx.dev), which installs what you need (either automatically or with the `dev` command). Core dependencies are typescript & npm; when building for other platforms, you'll need other things such as those in the pkgx.yaml & BUILDING.md files.
```bash
npm install
npm run dev

84
TASK_storage.md Normal file
View File

@@ -0,0 +1,84 @@
# What to do about storage for native apps?
## Problem
We can't trust iOS IndexedDB to persist. I want to start delivering an app to people now, in preparation for presentations mid-June: Rotary on June 12 and Porcfest on June 17.
* Apple WebKit puts a [7-day cap on IndexedDB](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/).
* The web standards expose a `persist` method to mark memory as persistent, and [supposedly WebView supports it](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persisted), but too many other things indicate it's not reliable. I've talked with [ChatGPT](https://chatgpt.com/share/68322f40-84c8-8007-b213-855f7962989a) & Venice & Claude (in Cursor); [this answer from Perplexity](https://www.perplexity.ai/search/which-platforms-prompt-the-use-HUQLqy4qQD2cRbkmO4CgHg) says that most platforms don't prompt and Safari doesn't support it; I don't know if that means WebKit as well.
* Capacitor says [not to trust it on iOS](https://capacitorjs.com/docs/v6/guides/storage).
Also, with sensitive data, the accounts info should be encrypted.
# Options
* There is a community [SQLite plugin for Capacitor](https://github.com/capacitor-community/sqlite) with encryption by [SQLCipher](https://github.com/sqlcipher/sqlcipher).
* [This tutorial](https://jepiqueau.github.io/2023/09/05/Ionic7Vue-SQLite-CRUD-App.html#part-1---web---table-of-contents) shows how that plugin works for web as well as native.
* Capacitor abstracts [user preferences in an API](https://capacitorjs.com/docs/apis/preferences), which uses different underlying libraries on iOS & Android. Unfortunately, it won't do any filtering or searching, and is only meant for small amounts of data. (It could be used for settings and for identifiers, but contacts will grow and image blobs won't work.)
* There are hints that Capacitor offers another custom storage API but all I could find was that Preferences API.
* [Ionic Storage](https://ionic.io/docs/secure-storage) is an enterprise solution, which also supports encryption.
* Not an option yet: Dexie may support SQLite in [a future version](https://dexie.org/roadmap/dexie5.0).
# Current Plan
* Implement SQLite for Capacitor & web, with encryption. That will allow us to test quickly and keep the same interface for native & web, but we don't deal with migrations for current web users.
* After that is delivered, write a migration for current web users from IndexedDB to SQLite.
# Current method calls
... which is not 100% complete because the AI that generated thus claimed no usage of 'temp' DB.
### Secret Database (secretDB) - Used for storing the encryption key
secretDB.open() - Opens the database
secretDB.secret.get(MASTER_SECRET_KEY) - Retrieves the secret key
secretDB.secret.add({ id: MASTER_SECRET_KEY, secret }) - Adds a new secret key
### Accounts Database (accountsDB) - Used for storing sensitive account information
accountsDB.open() - Opens the database
accountsDB.accounts.count() - Counts number of accounts
accountsDB.accounts.toArray() - Gets all accounts
accountsDB.accounts.where("did").equals(did).first() - Gets a specific account by DID
accountsDB.accounts.add(account) - Adds a new account
### Non-sensitive Database (db) - Used for settings, contacts, logs, and temp data
Settings operations:
export all settings (Dexie format)
db.settings.get(MASTER_SETTINGS_KEY) - Gets default settings
db.settings.where("accountDid").equals(did).first() - Gets account-specific settings
db.settings.where("accountDid").equals(did).modify(settingsChanges) - Updates account settings
db.settings.add(settingsChanges) - Adds new settings
db.settings.count() - Counts number of settings
db.settings.update(key, changes) - Updates settings
Contacts operations:
export all contacts (Dexie format)
db.contacts.toArray() - Gets all contacts
db.contacts.add(contact) - Adds a new contact
db.contacts.update(did, contactData) - Updates a contact
db.contacts.delete(did) - Deletes a contact
db.contacts.where("did").equals(did).first() - Gets a specific contact by DID
Logs operations:
db.logs.get(todayKey) - Gets logs for a specific day
db.logs.update(todayKey, { message: fullMessage }) - Updates logs
db.logs.clear() - Clears all logs

View File

@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10
versionName "0.4.4"
versionCode 18
versionName "0.4.7"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 48;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -106,6 +106,7 @@
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
);
buildRules = (
);
@@ -122,8 +123,9 @@
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 920;
LastUpgradeCheck = 1630;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
@@ -141,8 +143,6 @@
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -169,6 +169,26 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Fix Privacy Manifest";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" ";
showEnvVarsInLog = 0;
};
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -249,6 +269,7 @@
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
@@ -256,8 +277,10 @@
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -267,8 +290,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 7XVXYPEQYJ;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -306,6 +331,7 @@
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
@@ -313,8 +339,10 @@
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -324,8 +352,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 7XVXYPEQYJ;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -337,7 +367,8 @@
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
@@ -348,19 +379,23 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = GM3FS5JQPH;
CURRENT_PROJECT_VERSION = 18;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.4.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.4.7;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic"; /* allows agvtool to set *_VERSION settings */
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
@@ -370,18 +405,22 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = GM3FS5JQPH;
CURRENT_PROJECT_VERSION = 18;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 0.4.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.4.7;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic"; /* allows agvtool to set *_VERSION settings */
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};

View File

@@ -0,0 +1,5 @@
# macOS
.DS_Store
# Build
/Build/

View File

@@ -0,0 +1,58 @@
## 1.4.1
- Fix macOS app re-signing issue.
- Automatically enable Hardened Runtime in macOS codesign.
- Add clean script.
## 1.4.0
- Support for macOS app ([#9](https://github.com/crasowas/app_privacy_manifest_fixer/issues/9)).
## 1.3.11
- Fix install issue by skipping `PBXAggregateTarget` ([#4](https://github.com/crasowas/app_privacy_manifest_fixer/issues/4)).
## 1.3.10
- Fix app re-signing issue.
- Enhance Build Phases script robustness.
## 1.3.9
- Add log file output.
## 1.3.8
- Add version info to privacy access report.
- Remove empty tables from privacy access report.
## 1.3.7
- Enhance API symbols analysis with strings tool.
- Improve performance of API usage analysis.
## 1.3.5
- Fix issue with inaccurate privacy manifest search.
- Disable dependency analysis to force the script to run on every build.
- Add placeholder for privacy access report.
- Update build output directory naming convention.
- Add examples for privacy access report.
## 1.3.0
- Add privacy access report generation.
## 1.2.3
- Fix issue with relative path parameter.
- Add support for all application targets.
## 1.2.1
- Fix backup issue with empty user templates directory.
## 1.2.0
- Add uninstall script.
## 1.1.2
- Remove `Templates/.gitignore` to track `UserTemplates`.
- Fix incorrect use of `App.xcprivacy` template in `App.framework`.
## 1.1.0
- Add logs for latest release fetch failure.
- Fix issue with converting published time to local time.
- Disable showing environment variables in the build log.
- Add `--install-builds-only` command line option.
## 1.0.0
- Initial version.

View File

@@ -0,0 +1,80 @@
#!/bin/bash
# Copyright (c) 2025, crasowas.
#
# Use of this source code is governed by a MIT-style license
# that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
set -e
# Prevent duplicate loading
if [ -n "$CONSTANTS_SH_LOADED" ]; then
return
fi
readonly CONSTANTS_SH_LOADED=1
# File name of the privacy manifest
readonly PRIVACY_MANIFEST_FILE_NAME="PrivacyInfo.xcprivacy"
# Common privacy manifest template file names
readonly APP_TEMPLATE_FILE_NAME="AppTemplate.xcprivacy"
readonly FRAMEWORK_TEMPLATE_FILE_NAME="FrameworkTemplate.xcprivacy"
# Universal delimiter
readonly DELIMITER=":"
# Space escape symbol for handling space in path
readonly SPACE_ESCAPE="\u0020"
# Default value when the version cannot be retrieved
readonly UNKNOWN_VERSION="unknown"
# Categories of required reason APIs
readonly API_CATEGORIES=(
"NSPrivacyAccessedAPICategoryFileTimestamp"
"NSPrivacyAccessedAPICategorySystemBootTime"
"NSPrivacyAccessedAPICategoryDiskSpace"
"NSPrivacyAccessedAPICategoryActiveKeyboards"
"NSPrivacyAccessedAPICategoryUserDefaults"
)
# Symbol of the required reason APIs and their categories
#
# See also:
# * https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api
# * https://github.com/Wooder/ios_17_required_reason_api_scanner/blob/main/required_reason_api_binary_scanner.sh
readonly API_SYMBOLS=(
# NSPrivacyAccessedAPICategoryFileTimestamp
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}getattrlist"
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}getattrlistbulk"
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}fgetattrlist"
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}stat"
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}fstat"
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}fstatat"
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}lstat"
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}getattrlistat"
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSFileCreationDate"
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSFileModificationDate"
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSURLContentModificationDateKey"
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSURLCreationDateKey"
# NSPrivacyAccessedAPICategorySystemBootTime
"NSPrivacyAccessedAPICategorySystemBootTime${DELIMITER}systemUptime"
"NSPrivacyAccessedAPICategorySystemBootTime${DELIMITER}mach_absolute_time"
# NSPrivacyAccessedAPICategoryDiskSpace
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}statfs"
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}statvfs"
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}fstatfs"
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}fstatvfs"
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSFileSystemFreeSize"
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSFileSystemSize"
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeAvailableCapacityKey"
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeAvailableCapacityForImportantUsageKey"
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeAvailableCapacityForOpportunisticUsageKey"
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeTotalCapacityKey"
# NSPrivacyAccessedAPICategoryActiveKeyboards
"NSPrivacyAccessedAPICategoryActiveKeyboards${DELIMITER}activeInputModes"
# NSPrivacyAccessedAPICategoryUserDefaults
"NSPrivacyAccessedAPICategoryUserDefaults${DELIMITER}NSUserDefaults"
)

View File

@@ -0,0 +1,125 @@
#!/bin/bash
# Copyright (c) 2025, crasowas.
#
# Use of this source code is governed by a MIT-style license
# that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
set -e
# Prevent duplicate loading
if [ -n "$UTILS_SH_LOADED" ]; then
return
fi
readonly UTILS_SH_LOADED=1
# Absolute path of the script and the tool's root directory
script_path="$(realpath "${BASH_SOURCE[0]}")"
tool_root_path="$(dirname "$(dirname "$script_path")")"
# Load common constants
source "$tool_root_path/Common/constants.sh"
# Print the elements of an array along with their indices
function print_array() {
local -a array=("$@")
for ((i=0; i<${#array[@]}; i++)); do
echo "[$i] $(decode_path "${array[i]}")"
done
}
# Split a string into substrings using a specified delimiter
function split_string_by_delimiter() {
local string="$1"
local -a substrings=()
IFS="$DELIMITER" read -ra substrings <<< "$string"
echo "${substrings[@]}"
}
# Encode a path string by replacing space with an escape character
function encode_path() {
echo "$1" | sed "s/ /$SPACE_ESCAPE/g"
}
# Decode a path string by replacing encoded character with space
function decode_path() {
echo "$1" | sed "s/$SPACE_ESCAPE/ /g"
}
# Get the dependency name by removing common suffixes
function get_dependency_name() {
local path="$1"
local dir_name="$(basename "$path")"
# Remove `.app`, `.framework`, and `.xcframework` suffixes
local dep_name="${dir_name%.*}"
echo "$dep_name"
}
# Get the executable name from the specified `Info.plist` file
function get_plist_executable() {
local plist_file="$1"
if [ ! -f "$plist_file" ]; then
echo ""
else
/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$plist_file" 2>/dev/null || echo ""
fi
}
# Get the version from the specified `Info.plist` file
function get_plist_version() {
local plist_file="$1"
if [ ! -f "$plist_file" ]; then
echo "$UNKNOWN_VERSION"
else
/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$plist_file" 2>/dev/null || echo "$UNKNOWN_VERSION"
fi
}
# Get the path of the specified framework version
function get_framework_path() {
local path="$1"
local version_path="$2"
if [ -z "$version_path" ]; then
echo "$path"
else
echo "$path/$version_path"
fi
}
# Search for privacy manifest files in the specified directory
function search_privacy_manifest_files() {
local path="$1"
local -a privacy_manifest_files=()
# Create a temporary file to store search results
local temp_file="$(mktemp)"
# Ensure the temporary file is deleted on script exit
trap "rm -f $temp_file" EXIT
# Find privacy manifest files within the specified directory and store the results in the temporary file
find "$path" -type f -name "$PRIVACY_MANIFEST_FILE_NAME" -print0 2>/dev/null > "$temp_file"
while IFS= read -r -d '' file; do
privacy_manifest_files+=($(encode_path "$file"))
done < "$temp_file"
echo "${privacy_manifest_files[@]}"
}
# Get the privacy manifest file with the shortest path
function get_privacy_manifest_file() {
local privacy_manifest_file="$(printf "%s\n" "$@" | awk '{print length, $0}' | sort -n | head -n1 | cut -d ' ' -f2-)"
echo "$(decode_path "$privacy_manifest_file")"
}

View File

@@ -0,0 +1,80 @@
# Copyright (c) 2024, crasowas.
#
# Use of this source code is governed by a MIT-style license
# that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
require 'xcodeproj'
RUN_SCRIPT_PHASE_NAME = 'Fix Privacy Manifest'
if ARGV.length < 2
puts "Usage: ruby xcode_install_helper.rb <project_path> <script_content> [install_builds_only (true|false)]"
exit 1
end
project_path = ARGV[0]
run_script_content = ARGV[1]
install_builds_only = ARGV[2] == 'true'
# Find the first .xcodeproj file in the project directory
xcodeproj_path = Dir.glob(File.join(project_path, "*.xcodeproj")).first
# Validate the .xcodeproj file existence
unless xcodeproj_path
puts "Error: No .xcodeproj file found in the specified directory."
exit 1
end
# Open the Xcode project file
begin
project = Xcodeproj::Project.open(xcodeproj_path)
rescue StandardError => e
puts "Error: Unable to open the project file - #{e.message}"
exit 1
end
# Process all targets in the project
project.targets.each do |target|
# Skip PBXAggregateTarget
if target.is_a?(Xcodeproj::Project::Object::PBXAggregateTarget)
puts "Skipping aggregate target: #{target.name}."
next
end
# Check if the target is a native application target
if target.product_type == 'com.apple.product-type.application'
puts "Processing target: #{target.name}..."
# Check for an existing Run Script phase with the specified name
existing_phase = target.shell_script_build_phases.find { |phase| phase.name == RUN_SCRIPT_PHASE_NAME }
# Remove the existing Run Script phase if found
if existing_phase
puts " - Removing existing Run Script."
target.build_phases.delete(existing_phase)
end
# Add the new Run Script phase at the end
puts " - Adding new Run Script."
new_phase = target.new_shell_script_build_phase(RUN_SCRIPT_PHASE_NAME)
new_phase.shell_script = run_script_content
# Disable showing environment variables in the build log
new_phase.show_env_vars_in_log = '0'
# Run only for deployment post-processing if install_builds_only is true
new_phase.run_only_for_deployment_postprocessing = install_builds_only ? '1' : '0'
# Disable dependency analysis to force the script to run on every build, unless restricted to deployment builds by post-processing setting
new_phase.always_out_of_date = '1'
else
puts "Skipping non-application target: #{target.name}."
end
end
# Save the project file
begin
project.save
puts "Successfully added the Run Script phase: '#{RUN_SCRIPT_PHASE_NAME}'."
rescue StandardError => e
puts "Error: Unable to save the project file - #{e.message}"
exit 1
end

View File

@@ -0,0 +1,63 @@
# Copyright (c) 2024, crasowas.
#
# Use of this source code is governed by a MIT-style license
# that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
require 'xcodeproj'
RUN_SCRIPT_PHASE_NAME = 'Fix Privacy Manifest'
if ARGV.length < 1
puts "Usage: ruby xcode_uninstall_helper.rb <project_path>"
exit 1
end
project_path = ARGV[0]
# Find the first .xcodeproj file in the project directory
xcodeproj_path = Dir.glob(File.join(project_path, "*.xcodeproj")).first
# Validate the .xcodeproj file existence
unless xcodeproj_path
puts "Error: No .xcodeproj file found in the specified directory."
exit 1
end
# Open the Xcode project file
begin
project = Xcodeproj::Project.open(xcodeproj_path)
rescue StandardError => e
puts "Error: Unable to open the project file - #{e.message}"
exit 1
end
# Process all targets in the project
project.targets.each do |target|
# Check if the target is an application target
if target.product_type == 'com.apple.product-type.application'
puts "Processing target: #{target.name}..."
# Check for an existing Run Script phase with the specified name
existing_phase = target.shell_script_build_phases.find { |phase| phase.name == RUN_SCRIPT_PHASE_NAME }
# Remove the existing Run Script phase if found
if existing_phase
puts " - Removing existing Run Script."
target.build_phases.delete(existing_phase)
else
puts " - No existing Run Script found."
end
else
puts "Skipping non-application target: #{target.name}."
end
end
# Save the project file
begin
project.save
puts "Successfully removed the Run Script phase: '#{RUN_SCRIPT_PHASE_NAME}'."
rescue StandardError => e
puts "Error: Unable to save the project file - #{e.message}"
exit 1
end

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 crasowas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,240 @@
# App Privacy Manifest Fixer
[![Latest Version](https://img.shields.io/github/v/release/crasowas/app_privacy_manifest_fixer?logo=github)](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest)
![Supported Platforms](https://img.shields.io/badge/Supported%20Platforms-iOS%20%7C%20macOS-brightgreen)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
**English | [简体中文](./README.zh-CN.md)**
This tool is an automation solution based on Shell scripts, designed to analyze and fix the privacy manifest of iOS/macOS apps to ensure compliance with App Store requirements. It leverages the [App Store Privacy Manifest Analyzer](https://github.com/crasowas/app_store_required_privacy_manifest_analyser) to analyze API usage within the app and its dependencies, and generate or fix the `PrivacyInfo.xcprivacy` file.
## ✨ Features
- **Non-Intrusive Integration**: No need to modify the source code or adjust the project structure.
- **Fast Installation and Uninstallation**: Quickly install or uninstall the tool with a single command.
- **Automatic Analysis and Fixes**: Automatically analyzes API usage and fixes privacy manifest issues during the project build.
- **Flexible Template Customization**: Supports custom privacy manifest templates for apps and frameworks, accommodating various usage scenarios.
- **Privacy Access Report**: Automatically generates a report displaying the `NSPrivacyAccessedAPITypes` declarations for the app and SDKs.
- **Effortless Version Upgrades**: Provides an upgrade script for quick updates to the latest version.
## 📥 Installation
### Download the Tool
1. Download the [latest release](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest).
2. Extract the downloaded file:
- The extracted directory is usually named `app_privacy_manifest_fixer-xxx` (where `xxx` is the version number).
- It is recommended to rename it to `app_privacy_manifest_fixer` or use the full directory name in subsequent paths.
- **It is advised to move the directory to your iOS/macOS project to avoid path-related issues on different devices, and to easily customize the privacy manifest template for each project**.
### ⚡ Automatic Installation (Recommended)
1. **Navigate to the tool's directory**:
```shell
cd /path/to/app_privacy_manifest_fixer
```
2. **Run the installation script**:
```shell
sh install.sh <project_path>
```
- For Flutter projects, `project_path` should be the path to the `ios/macos` directory within the Flutter project.
- If the installation command is run again, the tool will first remove any existing installation (if present). To modify command-line options, simply rerun the installation command without the need to uninstall first.
### Manual Installation
If you prefer not to use the installation script, you can manually add the `Fix Privacy Manifest` task to the Xcode **Build Phases**. Follow these steps:
#### 1. Add the Script in Xcode
- Open your iOS/macOS project in Xcode, go to the **TARGETS** tab, and select your app target.
- Navigate to **Build Phases**, click the **+** button, and select **New Run Script Phase**.
- Rename the newly created **Run Script** to `Fix Privacy Manifest`.
- In the Shell script box, add the following code:
```shell
# Use relative path (recommended): if `app_privacy_manifest_fixer` is within the project directory
"$PROJECT_DIR/path/to/app_privacy_manifest_fixer/fixer.sh"
# Use absolute path: if `app_privacy_manifest_fixer` is outside the project directory
# "/absolute/path/to/app_privacy_manifest_fixer/fixer.sh"
```
**Modify `path/to` or `absolute/path/to` as needed, and ensure the paths are correct. Remove or comment out the unused lines accordingly.**
#### 2. Adjust the Script Execution Order
**Move this script after all other Build Phases to ensure the privacy manifest is fixed after all resource copying and build tasks are completed**.
### Build Phases Screenshot
Below is a screenshot of the Xcode Build Phases configuration after successful automatic/manual installation (with no command-line options enabled):
![Build Phases Screenshot](https://img.crasowas.dev/app_privacy_manifest_fixer/20250225011407.png)
## 🚀 Getting Started
After installation, the tool will automatically run with each project build, and the resulting application bundle will include the fixes.
If the `--install-builds-only` command-line option is enabled during installation, the tool will only run during the installation of the build.
### Xcode Build Log Screenshot
Below is a screenshot of the log output from the tool during the project build (by default, it will be saved to the `app_privacy_manifest_fixer/Build` directory, unless the `-s` command-line option is enabled):
![Xcode Build Log Screenshot](https://img.crasowas.dev/app_privacy_manifest_fixer/20250225011551.png)
## 📖 Usage
### Command Line Options
- **Force overwrite existing privacy manifest (Not recommended)**:
```shell
sh install.sh <project_path> -f
```
Enabling the `-f` option will force the tool to generate a new privacy manifest based on the API usage analysis and privacy manifest template, overwriting the existing privacy manifest. By default (without `-f`), the tool only fixes missing privacy manifests.
- **Silent mode**:
```shell
sh install.sh <project_path> -s
```
Enabling the `-s` option disables output during the fix process. The tool will no longer copy the generated `*.app`, automatically generate the privacy access report, or output the fix logs. By default (without `-s`), these outputs are stored in the `app_privacy_manifest_fixer/Build` directory.
- **Run only during installation builds (Recommended)**:
```shell
sh install.sh <project_path> --install-builds-only
```
Enabling the `--install-builds-only` option makes the tool run only during installation builds (such as the **Archive** operation), optimizing build performance for daily development. If you manually installed, this option is ineffective, and you need to manually check the **"For install builds only"** option.
**Note**: If the iOS/macOS project is built in a development environment (where the generated app contains `*.debug.dylib` files), the tool's API usage analysis results may be inaccurate.
### Upgrade the Tool
To update to the latest version, run the following command:
```shell
sh upgrade.sh
```
### Uninstall the Tool
To quickly uninstall the tool, run the following command:
```shell
sh uninstall.sh <project_path>
```
### Clean the Tool-Generated Files
To remove files generated by the tool, run the following command:
```shell
sh clean.sh
```
## 🔥 Privacy Manifest Templates
The privacy manifest templates are stored in the [`Templates`](https://github.com/crasowas/app_privacy_manifest_fixer/tree/main/Templates) directory, with the default templates already included in the root directory.
**How can you customize the privacy manifests for apps or SDKs? Simply use [custom templates](#custom-templates)!**
### Template Types
The templates are categorized as follows:
- **AppTemplate.xcprivacy**: A privacy manifest template for the app.
- **FrameworkTemplate.xcprivacy**: A generic privacy manifest template for frameworks.
- **FrameworkName.xcprivacy**: A privacy manifest template for a specific framework, available only in the `Templates/UserTemplates` directory.
### Template Priority
For an app, the priority of privacy manifest templates is as follows:
- `Templates/UserTemplates/AppTemplate.xcprivacy` > `Templates/AppTemplate.xcprivacy`
For a specific framework, the priority of privacy manifest templates is as follows:
- `Templates/UserTemplates/FrameworkName.xcprivacy` > `Templates/UserTemplates/FrameworkTemplate.xcprivacy` > `Templates/FrameworkTemplate.xcprivacy`
### Default Templates
The default templates are located in the `Templates` root directory and currently include the following templates:
- `Templates/AppTemplate.xcprivacy`
- `Templates/FrameworkTemplate.xcprivacy`
These templates will be modified based on the API usage analysis results, especially the `NSPrivacyAccessedAPIType` entries, to generate new privacy manifests for fixes, ensuring compliance with App Store requirements.
**If adjustments to the privacy manifest template are needed, such as in the following scenarios, avoid directly modifying the default templates. Instead, use a custom template. If a custom template with the same name exists, it will take precedence over the default template for fixes.**
- Generating a non-compliant privacy manifest due to inaccurate API usage analysis.
- Modifying the reason declared in the template.
- Adding declarations for collected data.
The privacy access API categories and their associated declared reasons in `AppTemplate.xcprivacy` are listed below:
| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) |
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| NSPrivacyAccessedAPICategoryFileTimestamp | C617.1: Inside app or group container |
| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device |
| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device |
| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device |
| NSPrivacyAccessedAPICategoryUserDefaults | CA92.1: Access info from same app |
The privacy access API categories and their associated declared reasons in `FrameworkTemplate.xcprivacy` are listed below:
| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) |
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| NSPrivacyAccessedAPICategoryFileTimestamp | 0A2A.1: 3rd-party SDK wrapper on-device |
| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device |
| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device |
| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device |
| NSPrivacyAccessedAPICategoryUserDefaults | C56D.1: 3rd-party SDK wrapper on-device |
### Custom Templates
To create custom templates, place them in the `Templates/UserTemplates` directory with the following structure:
- `Templates/UserTemplates/AppTemplate.xcprivacy`
- `Templates/UserTemplates/FrameworkTemplate.xcprivacy`
- `Templates/UserTemplates/FrameworkName.xcprivacy`
Among these templates, only `FrameworkTemplate.xcprivacy` will be modified based on the API usage analysis results to adjust the `NSPrivacyAccessedAPIType` entries, thereby generating a new privacy manifest for framework fixes. The other templates will remain unchanged and will be directly used for fixes.
**Important Notes:**
- The template for a specific framework must follow the naming convention `FrameworkName.xcprivacy`, where `FrameworkName` should match the name of the framework. For example, the template for `Flutter.framework` should be named `Flutter.xcprivacy`.
- For macOS frameworks, the naming convention should be `FrameworkName.Version.xcprivacy`, where the version name is added to distinguish different versions. For a single version macOS framework, the `Version` is typically `A`.
- The name of an SDK may not exactly match the name of the framework. To determine the correct framework name, check the `Frameworks` directory in the application bundle after building the project.
## 📑 Privacy Access Report
By default, the tool automatically generates privacy access reports for both the original and fixed versions of the app during each project build, and stores the reports in the `app_privacy_manifest_fixer/Build` directory.
If you need to manually generate a privacy access report for a specific app, run the following command:
```shell
sh Report/report.sh <app_path> <report_output_path>
# <app_path>: Path to the app (e.g., /path/to/App.app)
# <report_output_path>: Path to save the report file (e.g., /path/to/report.html)
```
**Note**: The report generated by the tool currently only includes the privacy access section (`NSPrivacyAccessedAPITypes`). To view the data collection section (`NSPrivacyCollectedDataTypes`), please use Xcode to generate the `PrivacyReport`.
### Sample Report Screenshots
| Original App Report (report-original.html) | Fixed App Report (report.html) |
|------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
| ![Original App Report](https://img.crasowas.dev/app_privacy_manifest_fixer/20241218230746.png) | ![Fixed App Report](https://img.crasowas.dev/app_privacy_manifest_fixer/20241218230822.png) |
## 💡 Important Considerations
- If the latest version of the SDK supports a privacy manifest, please upgrade as soon as possible to avoid unnecessary risks.
- This tool is a temporary solution and should not replace proper SDK management practices.
- Before submitting your app for review, ensure that the privacy manifest fix complies with the latest App Store requirements.
## 🙌 Contributing
Contributions in any form are welcome, including code optimizations, bug fixes, documentation improvements, and more. Please follow the project's guidelines and maintain a consistent coding style. Thank you for your support!

View File

@@ -0,0 +1,240 @@
# App Privacy Manifest Fixer
[![Latest Version](https://img.shields.io/github/v/release/crasowas/app_privacy_manifest_fixer?logo=github)](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest)
![Supported Platforms](https://img.shields.io/badge/Supported%20Platforms-iOS%20%7C%20macOS-brightgreen)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
**[English](./README.md) | 简体中文**
本工具是一个基于 Shell 脚本的自动化解决方案,旨在分析和修复 iOS/macOS App 的隐私清单,确保 App 符合 App Store 的要求。它利用 [App Store Privacy Manifest Analyzer](https://github.com/crasowas/app_store_required_privacy_manifest_analyser) 对 App 及其依赖项进行 API 使用分析,并生成或修复`PrivacyInfo.xcprivacy`文件。
## ✨ 功能特点
- **非侵入式集成**:无需修改源码或调整项目结构。
- **极速安装与卸载**:一行命令即可快速完成工具的安装或卸载。
- **自动分析与修复**:项目构建时自动分析 API 使用情况并修复隐私清单问题。
- **灵活定制模板**:支持自定义 App 和 Framework 的隐私清单模板,满足多种使用场景。
- **隐私访问报告**:自动生成报告用于查看 App 和 SDK 的`NSPrivacyAccessedAPITypes`声明情况。
- **版本轻松升级**:提供升级脚本快速更新至最新版本。
## 📥 安装
### 下载工具
1. 下载[最新发布版本](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest)。
2. 解压下载的文件:
- 解压后的目录通常为`app_privacy_manifest_fixer-xxx`(其中`xxx`是版本号)。
- 建议重命名为`app_privacy_manifest_fixer`,或在后续路径中使用完整目录名。
- **建议将该目录移动至 iOS/macOS 项目中,以避免因路径问题在不同设备上运行时出现错误,同时便于为每个项目单独自定义隐私清单模板**。
### ⚡ 自动安装(推荐)
1. **切换到工具所在目录**
```shell
cd /path/to/app_privacy_manifest_fixer
```
2. **运行以下安装脚本**
```shell
sh install.sh <project_path>
```
- 如果是 Flutter 项目,`project_path`应为 Flutter 项目中的`ios/macos`目录路径。
- 重复运行安装命令时,工具会先移除现有安装(如果有)。若需修改命令行选项,只需重新运行安装命令,无需先卸载。
### 手动安装
如果不使用安装脚本,可以手动添加`Fix Privacy Manifest`任务到 Xcode 的 **Build Phases** 完成安装。安装步骤如下:
#### 1. 在 Xcode 中添加脚本
- 用 Xcode 打开你的 iOS/macOS 项目,进入 **TARGETS** 选项卡,选择你的 App 目标。
- 进入 **Build Phases**,点击 **+** 按钮,选择 **New Run Script Phase**。
- 将新建的 **Run Script** 重命名为`Fix Privacy Manifest`。
- 在 Shell 脚本框中添加以下代码:
```shell
# 使用相对路径(推荐):如果`app_privacy_manifest_fixer`在项目目录内
"$PROJECT_DIR/path/to/app_privacy_manifest_fixer/fixer.sh"
# 使用绝对路径:如果`app_privacy_manifest_fixer`不在项目目录内
# "/absolute/path/to/app_privacy_manifest_fixer/fixer.sh"
```
**请根据实际情况修改`path/to`或`absolute/path/to`,并确保路径正确。同时,删除或注释掉不适用的行**。
#### 2. 调整脚本执行顺序
**将该脚本移动到所有其他 Build Phases 之后,确保隐私清单在所有资源拷贝和编译任务完成后再进行修复**。
### Build Phases 截图
下面是自动/手动安装成功后的 Xcode Build Phases 配置截图(未启用任何命令行选项):
![Build Phases Screenshot](https://img.crasowas.dev/app_privacy_manifest_fixer/20250225011407.png)
## 🚀 快速开始
安装后,工具将在每次构建项目时自动运行,构建完成后得到的 App 包已经是修复后的结果。
如果启用`--install-builds-only`命令行选项安装,工具将仅在安装构建时运行。
### Xcode Build Log 截图
下面是项目构建时工具输出的日志截图(默认会存储到`app_privacy_manifest_fixer/Build`目录,除非启用`-s`命令行选项):
![Xcode Build Log Screenshot](https://img.crasowas.dev/app_privacy_manifest_fixer/20250225011551.png)
## 📖 使用方法
### 命令行选项
- **强制覆盖现有隐私清单(不推荐)**
```shell
sh install.sh <project_path> -f
```
启用`-f`选项后,工具会根据 API 使用分析结果和隐私清单模板生成新的隐私清单,并强制覆盖现有隐私清单。默认情况下(未启用`-f`),工具仅修复缺失的隐私清单。
- **静默模式**
```shell
sh install.sh <project_path> -s
```
启用`-s`选项后,工具将禁用修复时的输出,不再复制构建生成的`*.app`、自动生成隐私访问报告或输出修复日志。默认情况下(未启用`-s`),这些输出存储在`app_privacy_manifest_fixer/Build`目录。
- **仅在安装构建时运行(推荐)**
```shell
sh install.sh <project_path> --install-builds-only
```
启用`--install-builds-only`选项后,工具仅在执行安装构建(如 **Archive** 操作)时运行,以优化日常开发时的构建性能。如果你是手动安装的,该命令行选项无效,需要手动勾选 **"For install builds only"** 选项。
**注意**:如果 iOS/macOS 项目在开发环境构建(生成的 App 包含`*.debug.dylib`文件),工具的 API 使用分析结果可能不准确。
### 升级工具
要更新至最新版本,请运行以下命令:
```shell
sh upgrade.sh
```
### 卸载工具
要快速卸载本工具,请运行以下命令:
```shell
sh uninstall.sh <project_path>
```
### 清理工具生成的文件
要删除工具生成的文件,请运行以下命令:
```shell
sh clean.sh
```
## 🔥 隐私清单模板
隐私清单模板存储在[`Templates`](https://github.com/crasowas/app_privacy_manifest_fixer/tree/main/Templates)目录,其中根目录已经包含默认模板。
**如何为 App 或 SDK 自定义隐私清单?只需使用[自定义模板](#自定义模板)**
### 模板类型
模板分为以下几类:
- **AppTemplate.xcprivacy**App 的隐私清单模板。
- **FrameworkTemplate.xcprivacy**:通用的 Framework 隐私清单模板。
- **FrameworkName.xcprivacy**:特定的 Framework 隐私清单模板,仅在`Templates/UserTemplates`目录有效。
### 模板优先级
对于 App隐私清单模板的优先级如下
- `Templates/UserTemplates/AppTemplate.xcprivacy` > `Templates/AppTemplate.xcprivacy`
对于特定的 Framework隐私清单模板的优先级如下
- `Templates/UserTemplates/FrameworkName.xcprivacy` > `Templates/UserTemplates/FrameworkTemplate.xcprivacy` > `Templates/FrameworkTemplate.xcprivacy`
### 默认模板
默认模板位于`Templates`根目录,目前包括以下模板:
- `Templates/AppTemplate.xcprivacy`
- `Templates/FrameworkTemplate.xcprivacy`
这些模板将根据 API 使用分析结果进行修改,特别是`NSPrivacyAccessedAPIType`条目将被调整,以生成新的隐私清单用于修复,确保符合 App Store 要求。
**如果需要调整隐私清单模板,例如以下场景,请避免直接修改默认模板,而是使用自定义模板。如果存在相同名称的自定义模板,它将优先于默认模板用于修复。**
- 由于 API 使用分析结果不准确,生成了不合规的隐私清单。
- 需要修改模板中声明的理由。
- 需要声明收集的数据。
`AppTemplate.xcprivacy`中隐私访问 API 类别及其对应声明的理由如下:
| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) |
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| NSPrivacyAccessedAPICategoryFileTimestamp | C617.1: Inside app or group container |
| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device |
| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device |
| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device |
| NSPrivacyAccessedAPICategoryUserDefaults | CA92.1: Access info from same app |
`FrameworkTemplate.xcprivacy`中隐私访问 API 类别及其对应声明的理由如下:
| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) |
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| NSPrivacyAccessedAPICategoryFileTimestamp | 0A2A.1: 3rd-party SDK wrapper on-device |
| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device |
| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device |
| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device |
| NSPrivacyAccessedAPICategoryUserDefaults | C56D.1: 3rd-party SDK wrapper on-device |
### 自定义模板
要创建自定义模板,请将其放在`Templates/UserTemplates`目录,结构如下:
- `Templates/UserTemplates/AppTemplate.xcprivacy`
- `Templates/UserTemplates/FrameworkTemplate.xcprivacy`
- `Templates/UserTemplates/FrameworkName.xcprivacy`
在这些模板中,只有`FrameworkTemplate.xcprivacy`会根据 API 使用分析结果对`NSPrivacyAccessedAPIType`条目进行调整,以生成新的隐私清单用于 Framework 修复。其他模板保持不变,将直接用于修复。
**重要说明:**
- 特定的 Framework 模板必须遵循命名规范`FrameworkName.xcprivacy`,其中`FrameworkName`需与 Framework 的名称匹配。例如`Flutter.framework`的模板应命名为`Flutter.xcprivacy`。
- 对于 macOS Framework应遵循命名规范`FrameworkName.Version.xcprivacy`,额外增加版本名称用于区分不同的版本。对于单一版本的 macOS Framework`Version`通常为`A`。
- SDK 的名称可能与 Framework 的名称不完全一致。要确定正确的 Framework 名称,请在构建项目后检查 App 包中的`Frameworks`目录。
## 📑 隐私访问报告
默认情况下,工具会自动在每次构建时为原始 App 和修复后的 App 生成隐私访问报告,并存储到`app_privacy_manifest_fixer/Build`目录。
如果需要手动为特定 App 生成隐私访问报告,请运行以下命令:
```shell
sh Report/report.sh <app_path> <report_output_path>
# <app_path>: App路径例如/path/to/App.app
# <report_output_path>: 报告文件保存路径(例如:/path/to/report.html
```
**注意**:工具生成的报告目前仅包含隐私访问部分(`NSPrivacyAccessedAPITypes`),如果想看数据收集部分(`NSPrivacyCollectedDataTypes`)请使用 Xcode 生成`PrivacyReport`。
### 报告示例截图
| 原始 App 报告report-original.html | 修复后 App 报告report.html |
|------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
| ![Original App Report](https://img.crasowas.dev/app_privacy_manifest_fixer/20241218230746.png) | ![Fixed App Report](https://img.crasowas.dev/app_privacy_manifest_fixer/20241218230822.png) |
## 💡 重要考量
- 如果最新版本的 SDK 支持隐私清单,请尽可能升级,以避免不必要的风险。
- 此工具仅为临时解决方案,不应替代正确的 SDK 管理实践。
- 在提交 App 审核之前,请检查隐私清单修复后是否符合最新的 App Store 要求。
## 🙌 贡献
欢迎任何形式的贡献包括代码优化、Bug 修复、文档改进等。请确保遵循项目规范,并保持代码风格一致。感谢你的支持!

View File

@@ -0,0 +1,124 @@
<!--
Copyright (c) 2024, crasowas.
Use of this source code is governed by a MIT-style license
that can be found in the LICENSE file or at
https://opensource.org/licenses/MIT.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Access Report</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
color: #333;
background-color: #f9f9f9;
line-height: 1.6;
}
.card {
background-color: #fff;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
padding: 20px;
min-width: 735px;
}
h2 {
font-size: 1.2em;
margin: 0 0 15px;
padding: 12px 20px;
color: #fff;
background-color: #5a9e6d;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
h2 .version {
font-size: 0.7em;
color: #5a9e6d;
background: #f1f1f1;
padding: 2px 6px;
border-radius: 6px;
}
a {
text-decoration: none;
color: #5a9e6d;
background-color: #fcfcfc;
padding: 8px 16px;
border: 1px solid #5a9e6d;
border-radius: 5px;
font-size: 0.9em;
margin-right: 16px;
transition: background-color 0.3s ease, color 0.3s ease;
}
a:hover {
color: #fff;
background-color: #5a9e6d;
}
a.warning {
color: #e0b73c;
background-color: #fcfcfc;
border: 1px solid #e0b73c;
}
a.warning:hover {
color: #fff;
background-color: #e0b73c;
}
table {
width: 100%;
border-collapse: collapse;
background-color: #fff;
border-radius: 8px;
overflow: hidden;
margin-top: 20px;
}
th,
td {
border: 1px solid #ddd;
padding: 12px 20px;
text-align: left;
}
th {
color: #fff;
background-color: #b0b8b1;
font-weight: bold;
}
tbody tr:nth-child(odd) {
background-color: #f9f9f9;
}
tbody tr:hover {
background-color: #f0f0f0;
}
</style>
</head>
<body>
<div class="card" style="display: flex; justify-content: space-between; align-items: center;">
<span>
This report was generated using version <strong>{{TOOL_VERSION}}</strong>.
</span>
<a href="https://github.com/crasowas/app_privacy_manifest_fixer" target="_blank">Like this
project? 🌟Star it on GitHub!</a>
</div>
{{REPORT_CONTENT}}
</body>
</html>

View File

@@ -0,0 +1,285 @@
#!/bin/bash
# Copyright (c) 2024, crasowas.
#
# Use of this source code is governed by a MIT-style license
# that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
set -e
# Absolute path of the script and the tool's root directory
script_path="$(realpath "$0")"
tool_root_path="$(dirname "$(dirname "$script_path")")"
# Load common constants and utils
source "$tool_root_path/Common/constants.sh"
source "$tool_root_path/Common/utils.sh"
# Path to the app
app_path="$1"
# Check if the app exists
if [ ! -d "$app_path" ] || [[ "$app_path" != *.app ]]; then
echo "Unable to find the app: $app_path"
exit 1
fi
# Check if the app is iOS or macOS
is_ios_app=true
frameworks_dir="$app_path/Frameworks"
if [ -d "$app_path/Contents/MacOS" ]; then
is_ios_app=false
frameworks_dir="$app_path/Contents/Frameworks"
fi
report_output_file="$2"
# Additional arguments as template usage records
template_usage_records=("${@:2}")
# Copy report template to output file
report_template_file="$tool_root_path/Report/report-template.html"
if ! rsync -a "$report_template_file" "$report_output_file"; then
echo "Failed to copy the report template to $report_output_file"
exit 1
fi
# Read the current tool's version from the VERSION file
tool_version_file="$tool_root_path/VERSION"
tool_version="N/A"
if [ -f "$tool_version_file" ]; then
tool_version="$(cat "$tool_version_file")"
fi
# Initialize report content
report_content=""
# Get the template file used for fixing based on the app or framework name
function get_used_template_file() {
local name="$1"
for template_usage_record in "${template_usage_records[@]}"; do
if [[ "$template_usage_record" == "$name$DELIMITER"* ]]; then
echo "${template_usage_record#*$DELIMITER}"
return
fi
done
echo ""
}
# Analyze accessed API types and their corresponding reasons
function analyze_privacy_accessed_api() {
local privacy_manifest_file="$1"
local -a results=()
if [ -f "$privacy_manifest_file" ]; then
local api_count=$(xmllint --xpath 'count(//dict/key[text()="NSPrivacyAccessedAPIType"])' "$privacy_manifest_file")
for ((i=1; i<=api_count; i++)); do
local api_type=$(xmllint --xpath "(//dict/key[text()='NSPrivacyAccessedAPIType']/following-sibling::string[1])[$i]/text()" "$privacy_manifest_file" 2>/dev/null)
local api_reasons=$(xmllint --xpath "(//dict/key[text()='NSPrivacyAccessedAPITypeReasons']/following-sibling::array[1])[position()=$i]/string/text()" "$privacy_manifest_file" 2>/dev/null | paste -sd "/" -)
if [ -z "$api_type" ]; then
api_type="N/A"
fi
if [ -z "$api_reasons" ]; then
api_reasons="N/A"
fi
results+=("$api_type$DELIMITER$api_reasons")
done
fi
echo "${results[@]}"
}
# Get the path to the `Info.plist` file for the specified app or framework
function get_plist_file() {
local path="$1"
local version_path="$2"
local plist_file=""
if [[ "$path" == *.app ]]; then
if [ "$is_ios_app" == true ]; then
plist_file="$path/Info.plist"
else
plist_file="$path/Contents/Info.plist"
fi
elif [[ "$path" == *.framework ]]; then
local framework_path="$(get_framework_path "$path" "$version_path")"
if [ "$is_ios_app" == true ]; then
plist_file="$framework_path/Info.plist"
else
plist_file="$framework_path/Resources/Info.plist"
fi
fi
echo "$plist_file"
}
# Add an HTML <div> element with the `card` class
function add_html_card_container() {
local card="$1"
report_content="$report_content<div class=\"card\">$card</div>"
}
# Generate an HTML <h2> element
function generate_html_header() {
local title="$1"
local version="$2"
echo "<h2>$title<span class=\"version\">Version $version</span></h2>"
}
# Generate an HTML <a> element with optional `warning` class
function generate_html_anchor() {
local text="$1"
local href="$2"
local warning="$3"
if [ "$warning" == true ]; then
echo "<a class=\"warning\" href=\"$href\">$text</a>"
else
echo "<a href=\"$href\">$text</a>"
fi
}
# Generate an HTML <table> element
function generate_html_table() {
local thead="$1"
local tbody="$2"
echo "<table>$thead$tbody</table>"
}
# Generate an HTML <thead> element
function generate_html_thead() {
local ths=("$@")
local tr=""
for th in "${ths[@]}"; do
tr="$tr<th>$th</th>"
done
echo "<thead><tr>$tr</tr></thead>"
}
# Generate an HTML <tbody> element
function generate_html_tbody() {
local trs=("$@")
local tbody=""
for tr in "${trs[@]}"; do
tbody="$tbody<tr>"
local tds=($(split_string_by_delimiter "$tr"))
for td in "${tds[@]}"; do
tbody="$tbody<td>$td</td>"
done
tbody="$tbody</tr>"
done
echo "<tbody>$tbody</tbody>"
}
# Generate the report content for the specified directory
function generate_report_content() {
local path="$1"
local version_path="$2"
local privacy_manifest_file=""
if [[ "$path" == *.app ]]; then
# Per the documentation, the privacy manifest should be placed at the root of the apps bundle for iOS, while for macOS, it should be located in `Contents/Resources/` within the apps bundle
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-app
if [ "$is_ios_app" == true ]; then
privacy_manifest_file="$path/$PRIVACY_MANIFEST_FILE_NAME"
else
privacy_manifest_file="$path/Contents/Resources/$PRIVACY_MANIFEST_FILE_NAME"
fi
else
# Per the documentation, the privacy manifest should be placed at the root of the iOS framework, while for a macOS framework with multiple versions, it should be located in the `Resources` directory within the corresponding version
# Some SDKs dont follow the guideline, so we use a search-based approach for now
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-framework
local framework_path="$(get_framework_path "$path" "$version_path")"
local privacy_manifest_files=($(search_privacy_manifest_files "$framework_path"))
privacy_manifest_file="$(get_privacy_manifest_file "${privacy_manifest_files[@]}")"
fi
local name="$(basename "$path")"
local title="$name"
if [ -n "$version_path" ]; then
title="$name ($version_path)"
fi
local plist_file="$(get_plist_file "$path" "$version_path")"
local version="$(get_plist_version "$plist_file")"
local card="$(generate_html_header "$title" "$version")"
if [ -f "$privacy_manifest_file" ]; then
card="$card$(generate_html_anchor "$PRIVACY_MANIFEST_FILE_NAME" "$privacy_manifest_file" false)"
local used_template_file="$(get_used_template_file "$name$version_path")"
if [ -f "$used_template_file" ]; then
card="$card$(generate_html_anchor "Template Used: $(basename "$used_template_file")" "$used_template_file" false)"
fi
local trs=($(analyze_privacy_accessed_api "$privacy_manifest_file"))
# Generate table only if the accessed privacy API types array is not empty
if [[ ${#trs[@]} -gt 0 ]]; then
local thead="$(generate_html_thead "NSPrivacyAccessedAPIType" "NSPrivacyAccessedAPITypeReasons")"
local tbody="$(generate_html_tbody "${trs[@]}")"
card="$card$(generate_html_table "$thead" "$tbody")"
fi
else
card="$card$(generate_html_anchor "Missing Privacy Manifest" "$path" true)"
fi
add_html_card_container "$card"
}
# Generate the report content for app
function generate_app_report_content() {
generate_report_content "$app_path" ""
}
# Generate the report content for frameworks
function generate_frameworks_report_content() {
if ! [ -d "$frameworks_dir" ]; then
return
fi
for path in "$frameworks_dir"/*; do
if [ -d "$path" ]; then
local versions_dir="$path/Versions"
if [ -d "$versions_dir" ]; then
for version in $(ls -1 "$versions_dir" | grep -vE '^Current$'); do
local version_path="Versions/$version"
generate_report_content "$path" "$version_path"
done
else
generate_report_content "$path" ""
fi
fi
done
}
# Generate the final report with all content
function generate_final_report() {
# Replace placeholders in the template with the tool's version and report content
sed -i "" -e "s|{{TOOL_VERSION}}|$tool_version|g" -e "s|{{REPORT_CONTENT}}|${report_content}|g" "$report_output_file"
echo "Privacy Access Report has been generated: $report_output_file"
}
generate_app_report_content
generate_frameworks_report_content
generate_final_report

View File

@@ -0,0 +1,55 @@
<?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>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryActiveKeyboards</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>54BD.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,55 @@
<?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>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>0A2A.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryActiveKeyboards</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>54BD.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C56D.1</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -0,0 +1 @@
v1.4.1

View File

@@ -0,0 +1,29 @@
#!/bin/bash
# Copyright (c) 2025, crasowas.
#
# Use of this source code is governed by a MIT-style license
# that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
set -e
target_paths=("Build")
echo "Cleaning..."
deleted_anything=false
for path in "${target_paths[@]}"; do
if [ -e "$path" ]; then
echo "Removing $path..."
rm -rf "./$path"
deleted_anything=true
fi
done
if [ "$deleted_anything" == true ]; then
echo "Cleanup completed."
else
echo "Nothing to clean."
fi

View File

@@ -0,0 +1,490 @@
#!/bin/bash
# Copyright (c) 2024, crasowas.
#
# Use of this source code is governed by a MIT-style license
# that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
set -e
# Absolute path of the script and the tool's root directory
script_path="$(realpath "$0")"
tool_root_path="$(dirname "$script_path")"
# Load common constants and utils
source "$tool_root_path/Common/constants.sh"
source "$tool_root_path/Common/utils.sh"
# Force replace the existing privacy manifest when the `-f` option is enabled
force=false
# Enable silent mode to disable build output when the `-s` option is enabled
silent=false
# Parse command-line options
while getopts ":fs" opt; do
case $opt in
f)
force=true
;;
s)
silent=true
;;
\?)
echo "Invalid option: -$OPTARG" >&2
exit 1
;;
esac
done
shift $((OPTIND - 1))
# Path of the app produced by the build process
app_path="${TARGET_BUILD_DIR}/${WRAPPER_NAME}"
# Check if the app exists
if [ ! -d "$app_path" ] || [[ "$app_path" != *.app ]]; then
echo "Unable to find the app: $app_path"
exit 1
fi
# Check if the app is iOS or macOS
is_ios_app=true
frameworks_dir="$app_path/Frameworks"
if [ -d "$app_path/Contents/MacOS" ]; then
is_ios_app=false
frameworks_dir="$app_path/Contents/Frameworks"
fi
# Default template directories
templates_dir="$tool_root_path/Templates"
user_templates_dir="$tool_root_path/Templates/UserTemplates"
# Use user-defined app privacy manifest template if it exists, otherwise fallback to default
app_template_file="$user_templates_dir/$APP_TEMPLATE_FILE_NAME"
if [ ! -f "$app_template_file" ]; then
app_template_file="$templates_dir/$APP_TEMPLATE_FILE_NAME"
fi
# Use user-defined framework privacy manifest template if it exists, otherwise fallback to default
framework_template_file="$user_templates_dir/$FRAMEWORK_TEMPLATE_FILE_NAME"
if [ ! -f "$framework_template_file" ]; then
framework_template_file="$templates_dir/$FRAMEWORK_TEMPLATE_FILE_NAME"
fi
# Disable build output in silent mode
if [ "$silent" == false ]; then
# Script used to generate the privacy access report
report_script="$tool_root_path/Report/report.sh"
# An array to record template usage for generating the privacy access report
template_usage_records=()
# Build output directory
build_dir="$tool_root_path/Build/${PRODUCT_NAME}-${CONFIGURATION}_${MARKETING_VERSION}_${CURRENT_PROJECT_VERSION}_$(date +%Y%m%d%H%M%S)"
# Ensure the build directory exists
mkdir -p "$build_dir"
# Redirect both stdout and stderr to a log file while keeping console output
exec > >(tee "$build_dir/fix.log") 2>&1
fi
# Get the path to the `Info.plist` file for the specified app or framework
function get_plist_file() {
local path="$1"
local version_path="$2"
local plist_file=""
if [[ "$path" == *.app ]]; then
if [ "$is_ios_app" == true ]; then
plist_file="$path/Info.plist"
else
plist_file="$path/Contents/Info.plist"
fi
elif [[ "$path" == *.framework ]]; then
local framework_path="$(get_framework_path "$path" "$version_path")"
if [ "$is_ios_app" == true ]; then
plist_file="$framework_path/Info.plist"
else
plist_file="$framework_path/Resources/Info.plist"
fi
fi
echo "$plist_file"
}
# Get the path to the executable for the specified app or framework
function get_executable_path() {
local path="$1"
local version_path="$2"
local executable_path=""
local plist_file="$(get_plist_file "$path" "$version_path")"
local executable_name="$(get_plist_executable "$plist_file")"
if [[ "$path" == *.app ]]; then
if [ "$is_ios_app" == true ]; then
executable_path="$path/$executable_name"
else
executable_path="$path/Contents/MacOS/$executable_name"
fi
elif [[ "$path" == *.framework ]]; then
local framework_path="$(get_framework_path "$path" "$version_path")"
executable_path="$framework_path/$executable_name"
fi
echo "$executable_path"
}
# Analyze the specified binary file for API symbols and their categories
function analyze_binary_file() {
local path="$1"
local -a results=()
# Check if the API symbol exists in the binary file using `nm` and `strings`
local nm_output=$(nm "$path" 2>/dev/null | xcrun swift-demangle)
local strings_output=$(strings "$path")
local combined_output="$nm_output"$'\n'"$strings_output"
for api_symbol in "${API_SYMBOLS[@]}"; do
local substrings=($(split_string_by_delimiter "$api_symbol"))
local category=${substrings[0]}
local api=${substrings[1]}
if echo "$combined_output" | grep -E "$api\$" >/dev/null; then
local index=-1
for ((i=0; i < ${#results[@]}; i++)); do
local result="${results[i]}"
local result_substrings=($(split_string_by_delimiter "$result"))
# If the category matches an existing result, update it
if [ "$category" == "${result_substrings[0]}" ]; then
index=i
results[i]="${result_substrings[0]}$DELIMITER${result_substrings[1]},$api$DELIMITER${result_substrings[2]}"
break
fi
done
# If no matching category found, add a new result
if [[ $index -eq -1 ]]; then
results+=("$category$DELIMITER$api$DELIMITER$(encode_path "$path")")
fi
fi
done
echo "${results[@]}"
}
# Analyze API usage in a binary file
function analyze_api_usage() {
local path="$1"
local version_path="$2"
local -a results=()
local binary_file="$(get_executable_path "$path" "$version_path")"
if [ -f "$binary_file" ]; then
results+=($(analyze_binary_file "$binary_file"))
fi
echo "${results[@]}"
}
# Get unique categories from analysis results
function get_categories() {
local results=("$@")
local -a categories=()
for result in "${results[@]}"; do
local substrings=($(split_string_by_delimiter "$result"))
local category=${substrings[0]}
if [[ ! "${categories[@]}" =~ "$category" ]]; then
categories+=("$category")
fi
done
echo "${categories[@]}"
}
# Get template file for the specified app or framework
function get_template_file() {
local path="$1"
local version_path="$2"
local template_file=""
if [[ "$path" == *.app ]]; then
template_file="$app_template_file"
else
# Give priority to the user-defined framework privacy manifest template
local dep_name="$(get_dependency_name "$path")"
if [ -n "$version_path" ]; then
dep_name="$dep_name.$(basename "$version_path")"
fi
local dep_template_file="$user_templates_dir/${dep_name}.xcprivacy"
if [ -f "$dep_template_file" ]; then
template_file="$dep_template_file"
else
template_file="$framework_template_file"
fi
fi
echo "$template_file"
}
# Check if the specified template file should be modified
#
# The following template files will be modified based on analysis:
# * Templates/AppTemplate.xcprivacy
# * Templates/FrameworkTemplate.xcprivacy
# * Templates/UserTemplates/FrameworkTemplate.xcprivacy
function is_template_modifiable() {
local template_file="$1"
local template_file_name="$(basename "$template_file")"
if [[ "$template_file" != "$user_templates_dir"* ]] || [ "$template_file_name" == "$FRAMEWORK_TEMPLATE_FILE_NAME" ]; then
return 0
else
return 1
fi
}
# Check if `Hardened Runtime` is enabled for the specified path
function is_hardened_runtime_enabled() {
local path="$1"
# Check environment variable first
if [ "${ENABLE_HARDENED_RUNTIME:-}" == "YES" ]; then
return 0
fi
# Check the code signature flags if environment variable is not set
local flags=$(codesign -dvvv "$path" 2>&1 | grep "flags=")
if echo "$flags" | grep -q "runtime"; then
return 0
else
return 1
fi
}
# Re-sign the target app or framework if code signing is enabled
function resign() {
local path="$1"
if [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ] && [ "${CODE_SIGNING_REQUIRED:-}" != "NO" ] && [ "${CODE_SIGNING_ALLOWED:-}" != "NO" ]; then
echo "Re-signing $path with Identity ${EXPANDED_CODE_SIGN_IDENTITY_NAME:-$EXPANDED_CODE_SIGN_IDENTITY}"
local codesign_cmd="/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} ${OTHER_CODE_SIGN_FLAGS:-} --preserve-metadata=identifier,entitlements"
if [ "$is_ios_app" == true ]; then
$codesign_cmd "$path"
else
if is_hardened_runtime_enabled "$path"; then
codesign_cmd="$codesign_cmd -o runtime"
fi
if [ -d "$path/Contents/MacOS" ]; then
find "$path/Contents/MacOS" -type f -name "*.dylib" | while read -r dylib_file; do
$codesign_cmd "$dylib_file"
done
fi
$codesign_cmd "$path"
fi
fi
}
# Fix the privacy manifest for the app or specified framework
# To accelerate the build, existing privacy manifests will be left unchanged unless the `-f` option is enabled
# After fixing, the app or framework will be automatically re-signed
function fix() {
local path="$1"
local version_path="$2"
local force_resign="$3"
local privacy_manifest_file=""
if [[ "$path" == *.app ]]; then
# Per the documentation, the privacy manifest should be placed at the root of the apps bundle for iOS, while for macOS, it should be located in `Contents/Resources/` within the apps bundle
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-app
if [ "$is_ios_app" == true ]; then
privacy_manifest_file="$path/$PRIVACY_MANIFEST_FILE_NAME"
else
privacy_manifest_file="$path/Contents/Resources/$PRIVACY_MANIFEST_FILE_NAME"
fi
else
# Per the documentation, the privacy manifest should be placed at the root of the iOS framework, while for a macOS framework with multiple versions, it should be located in the `Resources` directory within the corresponding version
# Some SDKs dont follow the guideline, so we use a search-based approach for now
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-framework
local framework_path="$(get_framework_path "$path" "$version_path")"
local privacy_manifest_files=($(search_privacy_manifest_files "$framework_path"))
privacy_manifest_file="$(get_privacy_manifest_file "${privacy_manifest_files[@]}")"
if [ -z "$privacy_manifest_file" ]; then
if [ "$is_ios_app" == true ]; then
privacy_manifest_file="$framework_path/$PRIVACY_MANIFEST_FILE_NAME"
else
privacy_manifest_file="$framework_path/Resources/$PRIVACY_MANIFEST_FILE_NAME"
fi
fi
fi
# Check if the privacy manifest file exists
if [ -f "$privacy_manifest_file" ]; then
echo "💡 Found privacy manifest file: $privacy_manifest_file"
if [ "$force" == false ]; then
if [ "$force_resign" == true ]; then
resign "$path"
fi
echo "✅ Privacy manifest file already exists, skipping fix."
return
fi
else
echo "⚠️ Missing privacy manifest file!"
fi
local results=($(analyze_api_usage "$path" "$version_path"))
echo "API usage analysis result(s): ${#results[@]}"
print_array "${results[@]}"
local template_file="$(get_template_file "$path" "$version_path")"
template_usage_records+=("$(basename "$path")$version_path$DELIMITER$template_file")
# Copy the template file to the privacy manifest location, overwriting if it exists
cp "$template_file" "$privacy_manifest_file"
if is_template_modifiable "$template_file"; then
local categories=($(get_categories "${results[@]}"))
local remove_categories=()
# Check if categories is non-empty
if [[ ${#categories[@]} -gt 0 ]]; then
# Convert categories to a single space-separated string for easy matching
local categories_set=" ${categories[*]} "
# Iterate through each element in `API_CATEGORIES`
for element in "${API_CATEGORIES[@]}"; do
# If element is not found in `categories_set`, add it to `remove_categories`
if [[ ! $categories_set =~ " $element " ]]; then
remove_categories+=("$element")
fi
done
else
# If categories is empty, add all of `API_CATEGORIES` to `remove_categories`
remove_categories=("${API_CATEGORIES[@]}")
fi
# Remove extra spaces in the XML file to simplify node removal
xmllint --noblanks "$privacy_manifest_file" -o "$privacy_manifest_file"
# Build a sed command to remove all matching nodes at once
local sed_pattern=""
for category in "${remove_categories[@]}"; do
# Find the node for the current category
local remove_node="$(xmllint --xpath "//dict[string='$category']" "$privacy_manifest_file" 2>/dev/null || true)"
# If the node is found, escape special characters and append it to the sed pattern
if [ -n "$remove_node" ]; then
local escaped_node=$(echo "$remove_node" | sed 's/[\/&]/\\&/g')
sed_pattern+="s/$escaped_node//g;"
fi
done
# Apply the combined sed pattern to the file if it's not empty
if [ -n "$sed_pattern" ]; then
sed -i "" "$sed_pattern" "$privacy_manifest_file"
fi
# Reformat the XML file to restore spaces for readability
xmllint --format "$privacy_manifest_file" -o "$privacy_manifest_file"
fi
resign "$path"
echo "✅ Privacy manifest file fixed: $privacy_manifest_file."
}
# Fix privacy manifests for all frameworks
function fix_frameworks() {
if ! [ -d "$frameworks_dir" ]; then
return
fi
echo "🛠️ Fixing Frameworks..."
for path in "$frameworks_dir"/*; do
if [ -d "$path" ]; then
local dep_name="$(get_dependency_name "$path")"
local versions_dir="$path/Versions"
if [ -d "$versions_dir" ]; then
for version in $(ls -1 "$versions_dir" | grep -vE '^Current$'); do
local version_path="Versions/$version"
echo "Analyzing $dep_name ($version_path) ..."
fix "$path" "$version_path" false
echo ""
done
else
echo "Analyzing $dep_name ..."
fix "$path" "" false
echo ""
fi
fi
done
}
# Fix the privacy manifest for the app
function fix_app() {
echo "🛠️ Fixing $(basename "$app_path" .app) App..."
# Since the framework may have undergone fixes, the app must be forcefully re-signed
fix "$app_path" "" true
echo ""
}
# Generate the privacy access report for the app
function generate_report() {
local original="$1"
if [ "$silent" == true ]; then
return
fi
local app_name="$(basename "$app_path")"
local name="${app_name%.*}"
local report_name=""
# Adjust output names if the app is flagged as original
if [ "$original" == true ]; then
app_name="${name}-original.app"
report_name="report-original.html"
else
app_name="$name.app"
report_name="report.html"
fi
local target_app_path="$build_dir/$app_name"
local report_path="$build_dir/$report_name"
echo "Copy app to $target_app_path"
rsync -a "$app_path/" "$target_app_path/"
# Generate the privacy access report using the script
sh "$report_script" "$target_app_path" "$report_path" "${template_usage_records[@]}"
echo ""
}
start_time=$(date +%s)
generate_report true
fix_frameworks
fix_app
generate_report false
end_time=$(date +%s)
echo "🎉 All fixed! ⏰ $((end_time - start_time)) seconds"
echo "🌟 If you found this script helpful, please consider giving it a star on GitHub. Your support is appreciated. Thank you!"
echo "🔗 Homepage: https://github.com/crasowas/app_privacy_manifest_fixer"
echo "🐛 Report issues: https://github.com/crasowas/app_privacy_manifest_fixer/issues"

View File

@@ -0,0 +1,71 @@
#!/bin/bash
# Copyright (c) 2024, crasowas.
#
# Use of this source code is governed by a MIT-style license
# that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
set -e
# Check if at least one argument (project_path) is provided
if [[ "$#" -lt 1 ]]; then
echo "Usage: $0 <project_path> [options...]"
exit 1
fi
project_path="$1"
shift
options=()
install_builds_only=false
# Check if the `--install-builds-only` option is provided and separate it from other options
for arg in "$@"; do
if [ "$arg" == "--install-builds-only" ]; then
install_builds_only=true
else
options+=("$arg")
fi
done
# Verify Ruby installation
if ! command -v ruby &>/dev/null; then
echo "Ruby is not installed. Please install Ruby and try again."
exit 1
fi
# Check if xcodeproj gem is installed
if ! gem list -i xcodeproj &>/dev/null; then
echo "The 'xcodeproj' gem is not installed."
read -p "Would you like to install it now? [Y/n] " response
if [[ "$response" =~ ^[Nn]$ ]]; then
echo "Please install 'xcodeproj' manually and re-run the script."
exit 1
fi
gem install xcodeproj || { echo "Failed to install 'xcodeproj'."; exit 1; }
fi
# Convert project path to an absolute path if it is relative
if [[ ! "$project_path" = /* ]]; then
project_path="$(realpath "$project_path")"
fi
# Absolute path of the script and the tool's root directory
script_path="$(realpath "$0")"
tool_root_path="$(dirname "$script_path")"
tool_portable_path="$tool_root_path"
# If the tool's root directory is inside the project path, make the path portable
if [[ "$tool_root_path" == "$project_path"* ]]; then
# Extract the path of the tool's root directory relative to the project path
tool_relative_path="${tool_root_path#$project_path}"
# Formulate a portable path using the `PROJECT_DIR` environment variable provided by Xcode
tool_portable_path="\${PROJECT_DIR}${tool_relative_path}"
fi
run_script_content="\"$tool_portable_path/fixer.sh\" ${options[@]}"
# Execute the Ruby helper script
ruby "$tool_root_path/Helper/xcode_install_helper.rb" "$project_path" "$run_script_content" "$install_builds_only"

View File

@@ -0,0 +1,46 @@
#!/bin/bash
# Copyright (c) 2024, crasowas.
#
# Use of this source code is governed by a MIT-style license
# that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
set -e
# Check if the project path is provided
if [[ $# -eq 0 ]]; then
echo "Usage: $0 <project_path>"
exit 1
fi
project_path="$1"
# Verify Ruby installation
if ! command -v ruby &>/dev/null; then
echo "Ruby is not installed. Please install Ruby and try again."
exit 1
fi
# Check if xcodeproj gem is installed
if ! gem list -i xcodeproj &>/dev/null; then
echo "The 'xcodeproj' gem is not installed."
read -p "Would you like to install it now? [Y/n] " response
if [[ "$response" =~ ^[Nn]$ ]]; then
echo "Please install 'xcodeproj' manually and re-run the script."
exit 1
fi
gem install xcodeproj || { echo "Failed to install 'xcodeproj'."; exit 1; }
fi
# Convert project path to an absolute path if it is relative
if [[ ! "$project_path" = /* ]]; then
project_path="$(realpath "$project_path")"
fi
# Absolute path of the script and the tool's root directory
script_path="$(realpath "$0")"
tool_root_path="$(dirname "$script_path")"
# Execute the Ruby helper script
ruby "$tool_root_path/Helper/xcode_uninstall_helper.rb" "$project_path"

View File

@@ -0,0 +1,108 @@
#!/bin/bash
# Copyright (c) 2024, crasowas.
#
# Use of this source code is governed by a MIT-style license
# that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
set -e
# Absolute path of the script and the tool's root directory
script_path="$(realpath "$0")"
tool_root_path="$(dirname "$script_path")"
# Repository details
readonly REPO_OWNER="crasowas"
readonly REPO_NAME="app_privacy_manifest_fixer"
# URL to fetch the latest release information
readonly LATEST_RELEASE_URL="https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases/latest"
# Fetch the release information from GitHub API
release_info=$(curl -s "$LATEST_RELEASE_URL")
# Extract the latest release version, download URL, and published time
latest_version=$(echo "$release_info" | grep -o '"tag_name": "[^"]*' | sed 's/"tag_name": "//')
download_url=$(echo "$release_info" | grep -o '"zipball_url": "[^"]*' | sed 's/"zipball_url": "//')
published_time=$(echo "$release_info" | grep -o '"published_at": "[^"]*' | sed 's/"published_at": "//')
# Ensure the latest version, download URL, and published time are successfully retrieved
if [ -z "$latest_version" ] || [ -z "$download_url" ] || [ -z "$published_time" ]; then
echo "Unable to fetch the latest release information."
echo "Request URL: $LATEST_RELEASE_URL"
echo "Response Data: $release_info"
exit 1
fi
# Convert UTC time to local time
published_time=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$published_time" +"%s" | xargs -I{} date -j -r {} +"%Y-%m-%d %H:%M:%S %z")
# Read the current tool's version from the VERSION file
tool_version_file="$tool_root_path/VERSION"
if [ ! -f "$tool_version_file" ]; then
echo "VERSION file not found."
exit 1
fi
local_version="$(cat "$tool_version_file")"
# Skip upgrade if the current version is already the latest
if [ "$local_version" == "$latest_version" ]; then
echo "Version $latest_version$published_time"
echo "Already up-to-date."
exit 0
fi
# Create a temporary directory for downloading the release
temp_dir=$(mktemp -d)
trap "rm -rf $temp_dir" EXIT
download_file_name="latest-release.tar.gz"
# Download the latest release archive
echo "Downloading version $latest_version..."
curl -L "$download_url" -o "$temp_dir/$download_file_name"
# Check if the download was successful
if [ $? -ne 0 ]; then
echo "Download failed, please check your network connection and try again."
exit 1
fi
# Extract the downloaded release archive
echo "Extracting files..."
tar -xzf "$temp_dir/$download_file_name" -C "$temp_dir"
# Find the extracted release
extracted_release_path=$(find "$temp_dir" -mindepth 1 -maxdepth 1 -type d -name "*$REPO_NAME*" | head -n 1)
# Verify that an extracted release was found
if [ -z "$extracted_release_path" ]; then
echo "No extracted release found for the latest version."
exit 1
fi
user_templates_dir="$tool_root_path/Templates/UserTemplates"
user_templates_backup_dir="$temp_dir/Templates/UserTemplates"
# Backup the user templates directory if it exists
if [ -d "$user_templates_dir" ]; then
echo "Backing up user templates..."
mkdir -p "$user_templates_backup_dir"
rsync -a --exclude='.*' "$user_templates_dir/" "$user_templates_backup_dir/"
fi
# Replace old version files with the new version files
echo "Replacing old version files..."
rsync -a --delete "$extracted_release_path/" "$tool_root_path/"
# Restore the user templates from the backup
if [ -d "$user_templates_backup_dir" ]; then
echo "Restoring user templates..."
rsync -a --exclude='.*' "$user_templates_backup_dir/" "$user_templates_dir/"
fi
# Upgrade complete
echo "Version $latest_version$published_time"
echo "Upgrade completed successfully!"

12
package-lock.json generated
View File

@@ -8764,9 +8764,9 @@
"dev": true
},
"node_modules/@types/node": {
"version": "20.17.48",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.48.tgz",
"integrity": "sha512-KpSfKOHPsiSC4IkZeu2LsusFwExAIVGkhG1KkbaBMLwau0uMhj0fCrvyg9ddM2sAvd+gtiBJLir4LAw1MNMIaw==",
"version": "20.17.50",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.50.tgz",
"integrity": "sha512-Mxiq0ULv/zo1OzOhwPqOA13I81CV/W3nvd3ChtQZRT5Cwz3cr0FKo/wMSsbTqL3EXpaBAEQhva2B8ByRkOIh9A==",
"dependencies": {
"undici-types": "~6.19.2"
}
@@ -27594,9 +27594,9 @@
}
},
"node_modules/zod": {
"version": "3.25.7",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.7.tgz",
"integrity": "sha512-YGdT1cVRmKkOg6Sq7vY7IkxdphySKnXhaUmFI4r4FcuFVNgpCb9tZfNwXbT6BPjD5oz0nubFsoo9pIqKrDcCvg==",
"version": "3.25.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.12.tgz",
"integrity": "sha512-A8e9eiwm2L5YEupdjn1YcxL1ZDWuL3Bc4JKvLwUrpKZZtRUDOijR7XXsV5WMuUCZPc1tFYO5yfH14STI08MquA==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -123,6 +123,7 @@
"vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.5.3",
"vue-router": "^4.5.0",
"wa-sqlite": "github:rhashimoto/wa-sqlite",
"web-did-resolver": "^2.0.27",
"zod": "^3.24.2"
},

View File

@@ -131,7 +131,20 @@ export default class DataExportSection extends Vue {
*/
public async exportDatabase() {
try {
const blob = await db.export({ prettyJson: true });
const blob = await db.export({
prettyJson: true,
transform: (table, value, key) => {
if (table === "contacts") {
// Dexie inserts a number 0 when some are undefined, so we need to totally remove them.
Object.keys(value).forEach(prop => {
if (value[prop] === undefined) {
delete value[prop];
}
});
}
return { value, key };
},
});
const fileName = `${db.name}-backup.json`;
if (this.platformCapabilities.hasFileDownload) {
@@ -155,7 +168,7 @@ export default class DataExportSection extends Vue {
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.",
: "You should have been prompted to save your backup file.",
},
-1,
);

View File

@@ -16,6 +16,36 @@
</div>
</div>
<!-- FEEDBACK: Show if camera preview is not visible after mounting -->
<div
v-if="!showCameraPreview && !blob && isRegistered"
class="bg-red-100 text-red-700 border border-red-400 rounded px-4 py-3 my-4 text-sm"
>
<strong>Camera preview not started.</strong>
<div v-if="cameraState === 'off'">
<span v-if="platformCapabilities.isMobile">
<b>Note:</b> This mobile browser may not support direct camera
access, or the app is treating it as a native app.<br />
<b>Tip:</b> Try using a desktop browser, or check if your browser
supports camera access for web apps.<br />
<b>Developer:</b> The platform detection logic may be skipping
camera preview for mobile browsers. <br />
<b>Action:</b> Review <code>platformCapabilities.isMobile</code> and
ensure web browsers on mobile are not treated as native apps.
</span>
<span v-else>
<b>Tip:</b> Your browser supports camera APIs, but the preview did
not start. Try refreshing the page or checking browser permissions.
</span>
</div>
<div v-else-if="cameraState === 'error'">
<b>Error:</b> {{ error || cameraStateMessage }}
</div>
<div v-else>
<b>Status:</b> {{ cameraStateMessage || "Unknown reason." }}
</div>
</div>
<div class="mt-4">
<template v-if="isRegistered">
<div v-if="!blob">
@@ -30,6 +60,57 @@
v-if="showCameraPreview"
class="camera-preview relative flex bg-black overflow-hidden mb-4"
>
<!-- Diagnostic Panel -->
<div
v-if="showDiagnostics"
class="absolute top-0 left-0 right-0 bg-black/80 text-white text-xs p-2 pt-8 z-20 overflow-auto max-h-[50vh]"
>
<div class="grid grid-cols-2 gap-2">
<div>
<p><strong>Camera State:</strong> {{ cameraState }}</p>
<p>
<strong>State Message:</strong>
{{ cameraStateMessage || "None" }}
</p>
<p><strong>Error:</strong> {{ error || "None" }}</p>
<p>
<strong>Preview Active:</strong>
{{ showCameraPreview ? "Yes" : "No" }}
</p>
<p>
<strong>Stream Active:</strong>
{{ !!cameraStream ? "Yes" : "No" }}
</p>
</div>
<div>
<p><strong>Browser:</strong> {{ userAgent }}</p>
<p>
<strong>HTTPS:</strong>
{{ isSecureContext ? "Yes" : "No" }}
</p>
<p>
<strong>MediaDevices:</strong>
{{ hasMediaDevices ? "Yes" : "No" }}
</p>
<p>
<strong>GetUserMedia:</strong>
{{ hasGetUserMedia ? "Yes" : "No" }}
</p>
<p>
<strong>Platform:</strong>
{{ platformCapabilities.isMobile ? "Mobile" : "Desktop" }}
</p>
</div>
</div>
</div>
<!-- Toggle Diagnostics Button -->
<button
class="absolute top-2 right-2 bg-black/50 text-white px-2 py-1 rounded text-xs z-30"
@click="toggleDiagnostics"
>
{{ showDiagnostics ? "Hide Diagnostics" : "Show Diagnostics" }}
</button>
<div class="camera-container w-full h-full relative">
<video
ref="videoElement"
@@ -104,14 +185,14 @@
dragMode: 'crop',
aspectRatio: 1 / 1,
}"
class="max-h-[90vh] max-w-[90vw] object-contain"
class="max-h-[50vh] max-w-[90vw] object-contain"
/>
</div>
<div v-else>
<div class="flex justify-center">
<img
:src="createBlobURL(blob)"
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain"
class="mt-2 rounded max-h-[50vh] max-w-[90vw] object-contain"
/>
</div>
</div>
@@ -227,12 +308,32 @@ export default class ImageMethodDialog extends Vue {
private platformCapabilities = this.platformService.getCapabilities();
// Add diagnostic properties
showDiagnostics = false;
userAgent = navigator.userAgent;
isSecureContext = window.isSecureContext;
hasMediaDevices = !!navigator.mediaDevices;
hasGetUserMedia = !!(
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
);
cameraState:
| "off"
| "initializing"
| "ready"
| "active"
| "error"
| "permission_denied"
| "not_found"
| "in_use" = "off";
cameraStateMessage?: string;
error: string | null = null;
/**
* Lifecycle hook: Initializes component and retrieves user settings
* @throws {Error} When settings retrieval fails
*/
async mounted() {
console.log("ImageMethodDialog mounted");
logger.log("ImageMethodDialog mounted");
try {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
@@ -267,7 +368,7 @@ export default class ImageMethodDialog extends Vue {
this.visible = true;
// Start camera preview immediately if not on mobile
if (!this.platformCapabilities.isMobile) {
if (!this.platformCapabilities.isNativeApp) {
this.startCameraPreview();
}
}
@@ -339,14 +440,23 @@ export default class ImageMethodDialog extends Vue {
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
logger.debug("Platform capabilities:", this.platformCapabilities);
if (this.platformCapabilities.isMobile) {
if (this.platformCapabilities.isNativeApp) {
logger.debug("Using platform service for mobile device");
this.cameraState = "initializing";
this.cameraStateMessage = "Using platform camera service...";
try {
const result = await this.platformService.takePicture();
this.blob = result.blob;
this.fileName = result.fileName;
this.cameraState = "ready";
this.cameraStateMessage = "Photo captured successfully";
} catch (error) {
logger.error("Error taking picture:", error);
this.cameraState = "error";
this.cameraStateMessage =
error instanceof Error ? error.message : "Failed to take picture";
this.error =
error instanceof Error ? error.message : "Failed to take picture";
this.$notify(
{
group: "alert",
@@ -362,13 +472,18 @@ export default class ImageMethodDialog extends Vue {
logger.debug("Starting camera preview for desktop browser");
try {
this.cameraState = "initializing";
this.cameraStateMessage = "Requesting camera access...";
this.showCameraPreview = true;
await this.$nextTick();
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
logger.debug("Camera access granted");
this.cameraStream = stream;
this.cameraState = "active";
this.cameraStateMessage = "Camera is active";
await this.$nextTick();
@@ -385,12 +500,30 @@ export default class ImageMethodDialog extends Vue {
}
} catch (error) {
logger.error("Error starting camera preview:", error);
let errorMessage =
error instanceof Error ? error.message : "Failed to access camera";
if (
error.name === "NotReadableError" ||
error.name === "TrackStartError"
) {
errorMessage =
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again.";
} else if (
error.name === "NotAllowedError" ||
error.name === "PermissionDeniedError"
) {
errorMessage =
"Camera access was denied. Please allow camera access in your browser settings.";
}
this.cameraState = "error";
this.cameraStateMessage = errorMessage;
this.error = errorMessage;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to access camera. Please try again.",
text: errorMessage,
},
5000,
);
@@ -404,6 +537,9 @@ export default class ImageMethodDialog extends Vue {
this.cameraStream = null;
}
this.showCameraPreview = false;
this.cameraState = "off";
this.cameraStateMessage = "Camera stopped";
this.error = null;
}
async capturePhoto() {
@@ -449,7 +585,7 @@ export default class ImageMethodDialog extends Vue {
async retryImage() {
this.blob = undefined;
if (!this.platformCapabilities.isMobile) {
if (!this.platformCapabilities.isNativeApp) {
await this.startCameraPreview();
}
}
@@ -533,6 +669,11 @@ export default class ImageMethodDialog extends Vue {
this.blob = undefined;
}
}
// Add toggle method
toggleDiagnostics() {
this.showDiagnostics = !this.showDiagnostics;
}
}
</script>
@@ -562,4 +703,11 @@ export default class ImageMethodDialog extends Vue {
display: flex;
flex-direction: column;
}
/* Add styles for diagnostic panel */
.diagnostic-panel {
font-family: monospace;
white-space: pre-wrap;
word-break: break-all;
}
</style>

28
src/db-sql/index.ts Normal file
View File

@@ -0,0 +1,28 @@
// Started with https://rhashimoto.github.io/wa-sqlite/docs/interfaces/SQLiteAPI.html
// and then https://github.com/rhashimoto/wa-sqlite/blob/master/demo/demo-worker.js
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite.mjs';
import * as SQLite from 'wa-sqlite';
const IDBBatchAtomicVFS = await import('wa-sqlite/src/examples/IDBBatchAtomicVFS.js');
let db: any;
export async function sql() {
if (!db) {
const module = await SQLiteESMFactory();
// Use the module to build the API instance.
const sqlite3 = SQLite.Factory(module);
const vfs = new IDBBatchAtomicVFS.create("IDBBatchAtomicVFS", module, { lockPolicy: 'shared+hint' });
sqlite3.vfs_register(vfs, true);
// Use the API to open and access a database.
const db = await sqlite3.open_v2("myDB");
console.log("----- sqlite3 db", db);
sqlite3.close(db);
}
return db;
}

View File

@@ -87,9 +87,79 @@ const DEFAULT_SETTINGS: Settings = {
// Event handler to initialize the non-sensitive database with default settings
db.on("populate", async () => {
await db.settings.add(DEFAULT_SETTINGS);
try {
await db.settings.add(DEFAULT_SETTINGS);
} catch (error) {
console.error("Error populating the database with default settings:", error);
}
});
// Helper function to safely open the database with retries
async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
// console.log("Starting safeOpenDatabase with retries:", retries);
for (let i = 0; i < retries; i++) {
try {
// console.log(`Attempt ${i + 1}: Checking if database is open...`);
if (!db.isOpen()) {
// console.log(`Attempt ${i + 1}: Database is closed, attempting to open...`);
// Create a promise that rejects after 5 seconds
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Database open timed out')), 500);
});
// Race between the open operation and the timeout
const openPromise = db.open();
// console.log(`Attempt ${i + 1}: Waiting for db.open() promise...`);
await Promise.race([openPromise, timeoutPromise]);
// If we get here, the open succeeded
// console.log(`Attempt ${i + 1}: Database opened successfully`);
return;
}
// console.log(`Attempt ${i + 1}: Database was already open`);
return;
} catch (error) {
console.error(`Attempt ${i + 1}: Database open failed:`, error);
if (i < retries - 1) {
console.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, delay));
} else {
throw error;
}
}
}
}
export async function updateDefaultSettings(
settingsChanges: Settings,
): Promise<number> {
delete settingsChanges.accountDid; // just in case
// ensure there is no "id" that would override the key
delete settingsChanges.id;
try {
try {
// console.log("Database state before open:", db.isOpen() ? "open" : "closed");
// console.log("Database name:", db.name);
// console.log("Database version:", db.verno);
await safeOpenDatabase();
} catch (openError: unknown) {
console.error("Failed to open database:", openError);
const errorMessage = openError instanceof Error ? openError.message : String(openError);
throw new Error(`Database connection failed: ${errorMessage}. Please try again or restart the app.`);
}
const result = await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
return result;
} catch (error) {
console.error("Error updating default settings:", error);
if (error instanceof Error) {
throw error; // Re-throw if it's already an Error with a message
} else {
throw new Error(`Failed to update settings: ${error}`);
}
}
}
// Manage the encryption key.
// It's not really secure to maintain the secret next to the user's data.
@@ -183,15 +253,6 @@ export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
}
}
export async function updateDefaultSettings(
settingsChanges: Settings,
): Promise<void> {
delete settingsChanges.accountDid; // just in case
// ensure there is no "id" that would override the key
delete settingsChanges.id;
await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
}
export async function updateAccountSettings(
accountDid: string,
settingsChanges: Settings,

View File

@@ -539,20 +539,23 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
const identity = JSON.stringify(newId);
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: derivationPath,
did: newId.did,
identity: identity,
mnemonic: mnemonic,
publicKeyHex: newId.keys[0].publicKeyHex,
});
try {
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: derivationPath,
did: newId.did,
identity: identity,
mnemonic: mnemonic,
publicKeyHex: newId.keys[0].publicKeyHex,
});
await updateDefaultSettings({ activeDid: newId.did });
//console.log("Updated default settings in util");
await updateDefaultSettings({ activeDid: newId.did });
} catch (error) {
console.error("Failed to update default settings:", error);
throw new Error("Failed to set default settings. Please try again or restart the app.");
}
await updateAccountSettings(newId.did, { isRegistered: false });
return newId.did;
};

View File

@@ -88,9 +88,9 @@ const routes: Array<RouteRecordRaw> = [
component: () => import("../views/ContactQRScanShowView.vue"),
},
{
path: "/contact-qr-scan",
name: "contact-qr-scan",
component: () => import("../views/ContactQRScanView.vue"),
path: "/contact-qr-scan-full",
name: "contact-qr-scan-full",
component: () => import("../views/ContactQRScanFullView.vue"),
},
{
path: "/contacts",
@@ -243,11 +243,6 @@ const routes: Array<RouteRecordRaw> = [
name: "recent-offers-to-user-projects",
component: () => import("../views/RecentOffersToUserProjectsView.vue"),
},
{
path: "/scan-contact",
name: "scan-contact",
component: () => import("../views/ContactScanView.vue"),
},
{
path: "/search-area",
name: "search-area",

View File

@@ -90,16 +90,60 @@ export class WebInlineQRScanner implements QRScannerService {
logger.error(
`[WebInlineQRScanner:${this.id}] Checking camera permissions...`,
);
const permissions = await navigator.permissions.query({
name: "camera" as PermissionName,
});
logger.error(
`[WebInlineQRScanner:${this.id}] Permission state:`,
permissions.state,
);
const granted = permissions.state === "granted";
this.updateCameraState(granted ? "ready" : "permission_denied");
return granted;
// First try the Permissions API if available
if (navigator.permissions && navigator.permissions.query) {
try {
const permissions = await navigator.permissions.query({
name: "camera" as PermissionName,
});
logger.error(
`[WebInlineQRScanner:${this.id}] Permission state from Permissions API:`,
permissions.state,
);
if (permissions.state === "granted") {
this.updateCameraState("ready", "Camera permissions granted");
return true;
}
} catch (permError) {
// Permissions API might not be supported, continue with getUserMedia check
logger.error(
`[WebInlineQRScanner:${this.id}] Permissions API not supported:`,
permError,
);
}
}
// If Permissions API is not available or didn't return granted,
// try a test getUserMedia call
try {
const testStream = await navigator.mediaDevices.getUserMedia({
video: true,
});
// If we get here, we have permission
testStream.getTracks().forEach((track) => track.stop());
this.updateCameraState("ready", "Camera permissions granted");
return true;
} catch (mediaError) {
const error = mediaError as Error;
logger.error(
`[WebInlineQRScanner:${this.id}] getUserMedia test failed:`,
{
name: error.name,
message: error.message,
},
);
if (
error.name === "NotAllowedError" ||
error.name === "PermissionDeniedError"
) {
this.updateCameraState("permission_denied", "Camera access denied");
return false;
}
// For other errors, we'll try requesting permissions explicitly
return false;
}
} catch (error) {
logger.error(
`[WebInlineQRScanner:${this.id}] Error checking camera permissions:`,
@@ -122,6 +166,7 @@ export class WebInlineQRScanner implements QRScannerService {
logger.error(
`[WebInlineQRScanner:${this.id}] Requesting camera permissions...`,
);
// First check if we have any video devices
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(
@@ -131,10 +176,12 @@ export class WebInlineQRScanner implements QRScannerService {
logger.error(`[WebInlineQRScanner:${this.id}] Found video devices:`, {
count: videoDevices.length,
devices: videoDevices.map((d) => ({ id: d.deviceId, label: d.label })),
userAgent: navigator.userAgent,
});
if (videoDevices.length === 0) {
logger.error(`[WebInlineQRScanner:${this.id}] No video devices found`);
this.updateCameraState("not_found", "No camera found on this device");
throw new Error("No camera found on this device");
}
@@ -148,58 +195,79 @@ export class WebInlineQRScanner implements QRScannerService {
},
);
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: "environment",
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
this.updateCameraState("ready", "Camera permissions granted");
// Stop the test stream immediately
stream.getTracks().forEach((track) => {
logger.error(`[WebInlineQRScanner:${this.id}] Stopping test track:`, {
kind: track.kind,
label: track.label,
readyState: track.readyState,
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: "environment",
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
track.stop();
});
return true;
this.updateCameraState("ready", "Camera permissions granted");
// Stop the test stream immediately
stream.getTracks().forEach((track) => {
logger.error(`[WebInlineQRScanner:${this.id}] Stopping test track:`, {
kind: track.kind,
label: track.label,
readyState: track.readyState,
});
track.stop();
});
return true;
} catch (mediaError) {
const error = mediaError as Error;
logger.error(
`[WebInlineQRScanner:${this.id}] Error requesting camera access:`,
{
name: error.name,
message: error.message,
userAgent: navigator.userAgent,
},
);
// Update state based on error type
if (
error.name === "NotFoundError" ||
error.name === "DevicesNotFoundError"
) {
this.updateCameraState("not_found", "No camera found on this device");
throw new Error("No camera found on this device");
} else if (
error.name === "NotAllowedError" ||
error.name === "PermissionDeniedError"
) {
this.updateCameraState("permission_denied", "Camera access denied");
throw new Error(
"Camera access denied. Please grant camera permission and try again",
);
} else if (
error.name === "NotReadableError" ||
error.name === "TrackStartError"
) {
this.updateCameraState(
"in_use",
"Camera is in use by another application",
);
throw new Error("Camera is in use by another application");
} else {
this.updateCameraState("error", error.message);
throw new Error(`Camera error: ${error.message}`);
}
}
} catch (error) {
const wrappedError =
error instanceof Error ? error : new Error(String(error));
// Update state based on error type
if (
wrappedError.name === "NotFoundError" ||
wrappedError.name === "DevicesNotFoundError"
) {
this.updateCameraState("not_found", "No camera found on this device");
throw new Error("No camera found on this device");
} else if (
wrappedError.name === "NotAllowedError" ||
wrappedError.name === "PermissionDeniedError"
) {
this.updateCameraState("permission_denied", "Camera access denied");
throw new Error(
"Camera access denied. Please grant camera permission and try again",
);
} else if (
wrappedError.name === "NotReadableError" ||
wrappedError.name === "TrackStartError"
) {
this.updateCameraState(
"in_use",
"Camera is in use by another application",
);
throw new Error("Camera is in use by another application");
} else {
this.updateCameraState("error", wrappedError.message);
throw new Error(`Camera error: ${wrappedError.message}`);
}
logger.error(
`[WebInlineQRScanner:${this.id}] Error in requestPermissions:`,
{
error: wrappedError.message,
stack: wrappedError.stack,
userAgent: navigator.userAgent,
},
);
throw wrappedError;
}
}

5
src/types/wa-sqlite.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module "wa-sqlite/src/examples/IDBBatchAtomicVFS.js" {
export const create: {
new (name: string, module: any, options?: { lockPolicy?: string }): any;
};
}

View File

@@ -75,6 +75,9 @@
>
Set Your Name
</button>
<p class="text-xs text-slate-500 mt-1">
(Don't worry: this is not visible to anyone until you share it with them. It's not sent to any servers.)
</p>
<UserNameDialog ref="userNameDialog" />
</span>
<div class="flex justify-center mt-4">
@@ -107,24 +110,7 @@
<font-awesome icon="camera" class="fa-fw" />
</div>
</template>
<template v-else>
<div
id="noticeBeforeUpload"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3"
role="alert"
aria-live="polite"
>
<p class="mb-2">
Before you can upload a photo, a friend needs to register you.
</p>
<router-link
:to="{ name: 'contact-qr' }"
class="inline-block text-md uppercase 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-4 py-2 rounded-md"
>
Share Your Info
</router-link>
</div>
</template>
<!-- If not registered, they don't need to see this at all. We show a prompt to register below. -->
</div>
<ImageMethodDialog
ref="imageMethodDialog"
@@ -217,7 +203,7 @@
</p>
<router-link
:to="{ name: 'contact-qr' }"
class="inline-block text-md uppercase 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-4 py-2 rounded-md"
class="inline-block 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-4 py-2 rounded-md"
>
Share Your Info
</router-link>
@@ -618,6 +604,7 @@
leave-to-class="opacity-0"
>
<div v-if="showContactImport()" class="mt-4">
<!-- Bulk import has an error
<div class="flex justify-center">
<button
class="block 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-6"
@@ -628,6 +615,7 @@
(which doesn't include Identifier Data)
</button>
</div>
-->
<div class="flex justify-center">
<button
class="block 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-6"
@@ -976,6 +964,7 @@ import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import Dexie from "dexie";
import "dexie-export-import";
// @ts-ignore - they aren't exporting it but it's there
import { ImportProgress } from "dexie-export-import";
import { LeafletMouseEvent } from "leaflet";
import * as R from "ramda";
@@ -1007,6 +996,7 @@ import {
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "../db/index";
import { sql } from "../db-sql/index";
import { Account } from "../db/tables/accounts";
import { Contact } from "../db/tables/contacts";
import {
@@ -1209,10 +1199,12 @@ export default class AccountViewView extends Vue {
title: "Cannot Set Notifications",
text: "This browser does not support notifications. Use Chrome, or install this to the home screen, or try other suggestions on the 'Troubleshoot your notifications' page.",
},
3000,
7000,
);
}
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
const db = await sql();
console.log("----- db", db);
}
beforeUnmount() {
@@ -1621,10 +1613,25 @@ export default class AccountViewView extends Vue {
*/
async submitImportFile() {
if (inputImportFileNameRef.value != null) {
await db.delete();
await Dexie.import(inputImportFileNameRef.value as Blob, {
progressCallback: this.progressCallback,
});
await db.delete()
.then(async () => {
// BulkError: settings.bulkAdd(): 1 of 21 operations failed. Errors: ConstraintError: Key already exists in the object store.
await Dexie.import(inputImportFileNameRef.value as Blob, {
progressCallback: this.progressCallback,
})
})
.catch((error) => {
logger.error("Error importing file:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Importing",
text: "There was an error in the import. Your identities and contacts may have been affected, so you may have to restore your identifier and use the contact import method.",
},
-1,
);
});
}
}

View File

@@ -1,24 +1,104 @@
<template>
<!-- CONTENT -->
<section id="Content" class="relativew-[100vw] h-[100vh]">
<section id="Content" class="relative w-[100vw] h-[100vh]">
<div
class="absolute inset-x-0 bottom-0 bg-black/50 p-6 pb-[calc(env(safe-area-inset-bottom)+1.5rem)]"
class="p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
>
<p class="text-center text-white mb-3">
Point your camera at a TimeSafari contact QR code to scan it
automatically.
</p>
<div class="mb-4">
<h1 class="text-xl text-center font-semibold relative">
<!-- Back -->
<a
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="handleBack"
>
<font-awesome icon="chevron-left" class="fa-fw" />
</a>
<p v-if="error" class="text-center text-rose-300 mb-3">{{ error }}</p>
<!-- Quick Help -->
<a
class="text-xl text-center text-blue-500 px-2 py-1 absolute -right-2 -top-1"
@click="toastQRCodeHelp()"
>
<font-awesome icon="circle-question" class="fa-fw" />
</a>
<div class="flex justify-center items-center">
Share Contact Info
</h1>
</div>
<div
v-if="!givenName"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mt-4"
>
<p class="mb-2">
<b>Note:</b> your identity currently does <b>not</b> include a name.
</p>
<button
class="text-center text-slate-600 leading-none bg-white p-2 rounded-full drop-shadow-lg"
@click="handleBack"
class="inline-block text-md uppercase 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-4 py-2 rounded-md"
@click="openUserNameDialog"
>
<font-awesome icon="xmark" class="size-6"></font-awesome>
Set Your Name
</button>
</div>
<UserNameDialog ref="userNameDialog" />
<div
v-if="activeDid && activeDid.startsWith(ETHR_DID_PREFIX)"
class="block w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto mt-4"
@click="onCopyUrlToClipboard()"
>
<!--
Play with display options: https://qr-code-styling.com/
See docs: https://www.npmjs.com/package/qr-code-generator-vue3
-->
<QRCodeVue3
:value="qrValue"
:width="606"
:height="606"
:corners-square-options="{ type: 'square' }"
:dots-options="{ type: 'square', color: '#000' }"
/>
</div>
<div v-else-if="activeDid" class="text-center mt-4">
<!-- Not an ETHR DID so force them to paste it. (Passkey Peer DIDs are too big.) -->
<span class="text-blue-500" @click="onCopyDidToClipboard()">
Click here to copy your DID to your clipboard.
</span>
<span>
Then give it to them so they can paste it in their list of People.
</span>
</div>
<div v-else class="text-center mt-4">
You have no identitifiers yet, so
<router-link
:to="{ name: 'start' }"
class="bg-blue-500 text-white px-1.5 py-1 rounded-md"
>
create your identifier.
</router-link>
<br />
If you don't do that first, these contacts won't see your activity.
</div>
</div>
<div
class="relative w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto border border-dashed border-white mt-8 aspect-square"
>
<p
class="absolute top-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm text-center py-2 z-10"
>
Position QR code in the frame
</p>
<p
v-if="error"
class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-sm text-center py-2 z-20 text-rose-400"
>
{{ error }}
</p>
</div>
</section>
</template>
@@ -33,18 +113,29 @@ import { NotificationIface } from "../constants/app";
import { db } from "../db/index";
import { Contact } from "../db/tables/contacts";
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt } from "../libs/crypto/vc";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { setVisibilityUtil } from "../libs/endorserServer";
import { useClipboard } from "@vueuse/core";
import QRCodeVue3 from "qr-code-generator-vue3";
import UserNameDialog from "../components/UserNameDialog.vue";
import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer";
import { retrieveAccountMetadata } from "../libs/util";
interface QRScanResult {
rawValue?: string;
barcode?: string;
}
interface IUserNameDialog {
open: (callback: (name: string) => void) => void;
}
@Component({
components: {
QuickNav,
QRCodeVue3,
UserNameDialog,
},
})
export default class ContactQRScan extends Vue {
@@ -55,11 +146,14 @@ export default class ContactQRScan extends Vue {
error: string | null = null;
activeDid = "";
apiServer = "";
givenName = "";
qrValue = "";
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
// Add new properties to track scanning state
private lastScannedValue: string = "";
private lastScanTime: number = 0;
private readonly SCAN_DEBOUNCE_MS = 2000; // Prevent duplicate scans within 2 seconds
private readonly SCAN_DEBOUNCE_MS = 5000; // Increased from 2000 to 5000ms to better handle mobile scanning
// Add cleanup tracking
private isCleaningUp = false;
@@ -70,6 +164,21 @@ export default class ContactQRScan extends Vue {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.givenName = settings.firstName || "";
const account = await retrieveAccountMetadata(this.activeDid);
if (account) {
const name =
(settings.firstName || "") +
(settings.lastName ? ` ${settings.lastName}` : "");
this.qrValue = await generateEndorserJwtUrlForAccount(
account,
!!settings.isRegistered,
name,
settings.profileImageUrl || "",
false,
);
}
} catch (error) {
logger.error("Error initializing component:", {
error: error instanceof Error ? error.message : String(error),
@@ -270,14 +379,12 @@ export default class ContactQRScan extends Vue {
notes: contactInfo.notes || "",
};
// Add contact and stop scanning
// Add contact but keep scanning
logger.info("Adding new contact to database:", {
did: contact.did,
name: contact.name,
});
await this.addNewContact(contact);
await this.stopScanning();
this.$router.back(); // Return to previous view after successful scan
} catch (error) {
logger.error("Error processing contact QR code:", {
error: error instanceof Error ? error.message : String(error),
@@ -420,6 +527,56 @@ export default class ContactQRScan extends Vue {
await this.cleanupScanner();
this.$router.back();
}
toastQRCodeHelp() {
this.$notify(
{
group: "alert",
type: "info",
title: "QR Code Help",
text: "Click the QR code to copy your contact info to your clipboard.",
},
5000,
);
}
onCopyUrlToClipboard() {
useClipboard()
.copy(this.qrValue)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: "Contact URL was copied to clipboard.",
},
2000,
);
});
}
onCopyDidToClipboard() {
useClipboard()
.copy(this.activeDid)
.then(() => {
this.$notify(
{
group: "alert",
type: "info",
title: "Copied",
text: "Your DID was copied to the clipboard. Have them paste it in the box on their 'People' screen to add you.",
},
5000,
);
});
}
openUserNameDialog() {
(this.$refs.userNameDialog as IUserNameDialog).open((name: string) => {
this.givenName = name;
});
}
}
</script>

View File

@@ -42,7 +42,7 @@
<div
v-if="activeDid && activeDid.startsWith(ETHR_DID_PREFIX)"
class="block w-[90vw] max-w-[40vh] mx-auto my-4"
class="block w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto my-4"
@click="onCopyUrlToClipboard()"
>
<!--
@@ -75,13 +75,13 @@
create your identifier.
</router-link>
<br />
If you don't that first, these contacts won't see your activity.
If you don't do that first, these contacts won't see your activity.
</div>
<div class="text-center mt-6">
<div
v-if="isScanning && !isNativePlatform"
class="relative aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[40vh] mx-auto"
v-if="isScanning"
class="relative aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
>
<!-- Status Message -->
<div
@@ -142,8 +142,9 @@
</div>
<qrcode-stream
v-if="useQRReader && !isNativePlatform"
v-if="useQRReader"
:camera="preferredCamera"
class="qr-scanner"
@decode="onDecode"
@init="onInit"
@detect="onDetect"
@@ -167,17 +168,9 @@
</div>
<div
v-else
class="flex items-center justify-center aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[40vh] mx-auto"
class="flex items-center justify-center aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
>
<button
v-if="isNativePlatform"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white text-lg px-3 py-2 rounded-lg"
@click="$router.push({ name: 'contact-qr-scan' })"
>
Scan QR Code
</button>
<button
v-else
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white text-lg px-3 py-2 rounded-lg"
@click="startScanning"
>
@@ -193,7 +186,6 @@ import { AxiosError } from "axios";
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { Capacitor } from "@capacitor/core";
import { QrcodeStream } from "vue-qrcode-reader";
import QuickNav from "../components/QuickNav.vue";
@@ -244,7 +236,6 @@ export default class ContactQRScanShow extends Vue {
qrValue = "";
isScanning = false;
error: string | null = null;
isNativePlatform = Capacitor.isNativePlatform();
// QR Scanner properties
isInitializing = true;
@@ -265,6 +256,9 @@ export default class ContactQRScanShow extends Vue {
private isCleaningUp = false;
private isMounted = false;
// Add property to track if we're on desktop
private isDesktop = false;
async created() {
try {
const settings = await retrieveSettingsForActiveAccount();
@@ -534,13 +528,12 @@ export default class ContactQRScanShow extends Vue {
notes: contactInfo.notes || "",
};
// Add contact and stop scanning
// Add contact but keep scanning
logger.info("Adding new contact to database:", {
did: contact.did,
name: contact.name,
});
await this.addNewContact(contact);
await this.stopScanning();
} catch (error) {
logger.error("Error processing contact QR code:", {
error: error instanceof Error ? error.message : String(error),
@@ -692,6 +685,7 @@ export default class ContactQRScanShow extends Vue {
{
group: "alert",
type: "info",
title: "QR Code Help",
text: "Click the QR code to copy your contact info to your clipboard.",
},
5000,
@@ -724,12 +718,19 @@ export default class ContactQRScanShow extends Vue {
// Lifecycle hooks
mounted() {
this.isMounted = true;
this.isDesktop = this.detectDesktopBrowser();
document.addEventListener("pause", this.handleAppPause);
document.addEventListener("resume", this.handleAppResume);
// Start scanning automatically when view is loaded, but only on web platform
if (!this.isNativePlatform) {
this.startScanning();
}
// Start scanning automatically when view is loaded
this.startScanning();
// Apply mirroring after a short delay to ensure video element is ready
setTimeout(() => {
const videoElement = document.querySelector('.qr-scanner video') as HTMLVideoElement;
if (videoElement) {
videoElement.style.transform = 'scaleX(-1)';
}
}, 1000);
}
beforeDestroy() {
@@ -856,11 +857,6 @@ export default class ContactQRScanShow extends Vue {
async onInit(promise: Promise<void>): Promise<void> {
logger.log("[QRScanner] onInit called");
if (this.isNativePlatform) {
logger.log("Skipping QR scanner initialization on native platform");
return;
}
try {
await promise;
this.isInitializing = false;
@@ -889,7 +885,7 @@ export default class ContactQRScanShow extends Vue {
onDetect(result: unknown): void {
this.isScanning = true;
this.cameraState = "detecting";
this.cameraState = "active";
try {
let rawValue: string | undefined;
if (
@@ -899,7 +895,7 @@ export default class ContactQRScanShow extends Vue {
) {
rawValue = result[0].rawValue;
} else if (result && typeof result === "object" && "rawValue" in result) {
rawValue = result.rawValue;
rawValue = (result as { rawValue: string }).rawValue;
}
if (rawValue) {
this.isInitializing = false;
@@ -909,7 +905,6 @@ export default class ContactQRScanShow extends Vue {
} catch (error) {
this.handleError(error);
} finally {
this.isScanning = false;
this.cameraState = "active";
}
}
@@ -944,6 +939,22 @@ export default class ContactQRScanShow extends Vue {
stack: error.stack,
});
}
// Add method to detect desktop browser
private detectDesktopBrowser(): boolean {
const userAgent = navigator.userAgent.toLowerCase();
return !/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
}
// Update the computed property for camera mirroring
get shouldMirrorCamera(): boolean {
// On desktop, always mirror the webcam
if (this.isDesktop) {
return true;
}
// On mobile, mirror only for front-facing camera
return this.preferredCamera === "user";
}
}
</script>
@@ -951,4 +962,18 @@ export default class ContactQRScanShow extends Vue {
.aspect-square {
aspect-ratio: 1 / 1;
}
/* Update styles for camera mirroring */
:deep(.qr-scanner) {
position: relative;
}
:deep(.qr-scanner video) {
transform: scaleX(-1);
}
/* Ensure the canvas for QR detection is not mirrored */
:deep(.qr-scanner canvas) {
transform: none;
}
</style>

View File

@@ -1,92 +0,0 @@
<template>
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Breadcrumb -->
<div id="ViewBreadcrumb" class="mb-8">
<h1 class="text-lg text-center font-light relative px-7">
<!-- Cancel -->
<router-link
:to="{ name: 'account' }"
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
><font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
</router-link>
Scan Contact
</h1>
</div>
<h3 class="text-sm uppercase font-semibold mb-2">Scan a QR Code</h3>
<div class="bg-black rounded overflow-hidden relative mb-4">
<img src="https://picsum.photos/400/400?random=1" class="w-full" />
<!-- Darken overlay -->
<!-- Top -->
<div class="absolute top-0 left-0 right-0 bg-black/50 h-1/4"></div>
<!-- Reft -->
<div class="absolute top-1/4 bottom-1/4 left-0 bg-black/50 w-1/4"></div>
<!-- Right -->
<div class="absolute top-1/4 bottom-1/4 right-0 bg-black/50 w-1/4"></div>
<!-- Bottom -->
<div class="absolute bottom-0 left-0 right-0 bg-black/50 h-1/4"></div>
<!-- Reticle overlay -->
<!-- Top-left -->
<div
class="absolute top-1/4 left-1/4 h-6 w-6 border-white border-t-4 border-l-4 drop-shadow"
></div>
<!-- Top-right -->
<div
class="absolute top-1/4 right-1/4 h-6 w-6 border-white border-t-4 border-r-4 drop-shadow"
></div>
<!-- Bottom-left -->
<div
class="absolute bottom-1/4 left-1/4 h-6 w-6 border-white border-b-4 border-l-4 drop-shadow"
></div>
<!-- Bottom-right -->
<div
class="absolute bottom-1/4 right-1/4 h-6 w-6 border-white border-b-4 border-r-4 drop-shadow"
></div>
</div>
<h3 class="text-sm uppercase font-semibold mb-2">or Enter Contact Data</h3>
<input
type="text"
placeholder="Name (optional)"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
/>
<input
type="text"
placeholder="ID"
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
/>
<input
type="text"
placeholder="Public Key (optional)"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
/>
<div class="mt-8">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<input
type="submit"
class="block w-full text-center text-lg font-bold uppercase 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-2 py-3 rounded-md"
value="Look Up Contact"
/>
<button
type="button"
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
>
Cancel
</button>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
@Component({
components: {},
})
export default class ContactScanView extends Vue {}
</script>

View File

@@ -69,12 +69,12 @@
</span>
</span>
<router-link
:to="{ name: 'contact-qr' }"
<button
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
@click="handleQRCodeClick"
>
<font-awesome icon="qrcode" class="fa-fw text-2xl" />
</router-link>
</button>
<textarea
v-model="contactInput"
@@ -353,6 +353,7 @@ import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import { Capacitor } from "@capacitor/core";
import QuickNav from "../components/QuickNav.vue";
import EntityIcon from "../components/EntityIcon.vue";
@@ -1438,5 +1439,13 @@ export default class ContactsView extends Vue {
);
}
}
private handleQRCodeClick() {
if (Capacitor.isNativePlatform()) {
this.$router.push({ name: "contact-qr-scan-full" });
} else {
this.$router.push({ name: "contact-qr" });
}
}
}
</script>

View File

@@ -549,8 +549,8 @@
<h2 class="text-xl font-semibold">Where can I read more?</h2>
<p>
This is part of the
<a href="https://livesofgiving.org" target="_blank" class="text-blue-500">
Lives of Giving
<a href="https://livesofimpact.org" target="_blank" class="text-blue-500">
Lives of Impact
</a>
initiative.
</p>

View File

@@ -740,7 +740,7 @@ export default class HomeView extends Vue {
* - Displays user notification
*
* @internal
* Called by mounted() and handleFeedError()
* Called by mounted()
* @param err Error object with optional userMessage
*/
private handleError(err: unknown) {

View File

@@ -32,6 +32,13 @@
size="128"
></font-awesome>
</div>
<div v-else-if="hitError">
<span class="text-xl">Error Creating Identity</span>
<font-awesome icon="exclamation-triangle" class="fa-fw text-red-500 ml-2"></font-awesome>
<p class="text-sm text-gray-500">
Try fully restarting the app. If that doesn't work, back up all data (identities and other data) and reinstall the app.
</p>
</div>
<div v-else>
<span class="text-xl">Created!</span>
<font-awesome
@@ -62,14 +69,24 @@ import QuickNav from "../components/QuickNav.vue";
@Component({ components: { QuickNav } })
export default class NewIdentifierView extends Vue {
loading = true;
hitError = false;
$router!: Router;
async mounted() {
await generateSaveAndActivateIdentity();
this.loading = false;
setTimeout(() => {
this.$router.push({ name: "home" });
}, 1000);
this.loading = true;
this.hitError = false;
generateSaveAndActivateIdentity()
.then(() => {
this.loading = false;
setTimeout(() => {
this.$router.push({ name: "home" });
}, 1000);
})
.catch((error) => {
this.loading = false;
this.hitError = true;
console.error('Failed to generate identity:', error);
});
}
}
</script>