diff --git a/CHANGELOG.md b/CHANGELOG.md
index adda5c5..0a0d928 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.0.16] - 2026-07-26
+
+### Added
+
+- Native iPhone notification registration now belongs to the signed-in 1Helm
+ profile. Background channel and resident-agent updates use a
+ durable, retryable, idempotent delivery queue, honor global sound and
+ per-channel mute choices, skip the author, encrypt device tokens in the push
+ relay, and open the relevant channel or thread when tapped.
+- Settings → Notifications now lets a phone owner explicitly request system
+ notification permission, see whether the current phone is registered, and
+ turn registration off without changing another device.
+
+### Changed
+
+- Phone channel chrome now uses two calm rows: hamburger and channel name on
+ top, then a right-aligned row for Favorite, Router, resident status,
+ Terminal, Notes, and Skipper. Desktop remains one compact row, and every
+ phone action retains a 44-point target without horizontal overflow.
+- The native iOS status surface matches the current light or dark page header
+ behind the Dynamic Island while WebView controls remain physically below the
+ system indicators.
+
+### Fixed
+
+- Choosing **Take Photo or Video** from a message attachment no longer causes
+ iOS to terminate 1Helm. Camera, photo-library, and video-microphone access now
+ have narrow user-facing privacy declarations tied to explicit attachment
+ actions.
+- The Notes top bar shares the mobile surface treatment and keeps **New note**
+ and **Close** visible on a 390-point phone viewport.
+
## [0.0.15] - 2026-07-26
### Fixed
@@ -406,7 +438,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
notarization, stapled tickets, Gatekeeper verification, persistent
Application Support, and isolated Apple container machines.
-[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.15...HEAD
+[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.16...HEAD
+[0.0.16]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.16
[0.0.15]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.15
[0.0.14]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.14
[0.0.13]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.13
diff --git a/README.md b/README.md
index d5424f6..f6222a8 100644
--- a/README.md
+++ b/README.md
@@ -153,6 +153,10 @@ Android Keystore.
server, and do not retain host data or provider credentials beyond the
selected server address and secure session token. Use **Disconnect** in the
profile menu to erase both from the device.
+- iPhone notifications are opt-in under **Settings → Notifications**. Device
+ registration belongs to the signed-in account, per-channel mute still wins,
+ and tapping an update opens its channel or thread. iOS camera and photo
+ access is requested only after an explicit attachment action.
## Ready on day one. Specialized by day one hundred.
@@ -282,7 +286,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to
| `PORT` | `8123` | HTTP/WebSocket control-plane port. |
| `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. |
| `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. |
-| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.15` | Versioned channel-machine image contract. |
+| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.16` | Versioned channel-machine image contract. |
### Agent-first JSON CLI
diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle
index 8062541..07d5617 100644
--- a/android/app/capacitor.build.gradle
+++ b/android/app/capacitor.build.gradle
@@ -13,6 +13,7 @@ dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-keyboard')
+ implementation project(':capacitor-push-notifications')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar')
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 470cdd3..89972f3 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -49,4 +49,6 @@
+
+
diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle
index fdbf680..1349870 100644
--- a/android/capacitor.settings.gradle
+++ b/android/capacitor.settings.gradle
@@ -14,6 +14,9 @@ project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
+include ':capacitor-push-notifications'
+project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
+
include ':capacitor-splash-screen'
project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android')
diff --git a/capacitor.config.json b/capacitor.config.json
index 3d425d7..2d662b9 100644
--- a/capacitor.config.json
+++ b/capacitor.config.json
@@ -3,7 +3,7 @@
"appName": "1Helm",
"webDir": "public",
"loggingBehavior": "none",
- "backgroundColor": "#08090c",
+ "backgroundColor": "#111318",
"zoomEnabled": false,
"appendUserAgent": " 1HelmMobile",
"server": {
@@ -48,7 +48,10 @@
"StatusBar": {
"overlaysWebView": false,
"style": "DEFAULT",
- "backgroundColor": "#08090c"
+ "backgroundColor": "#111318"
+ },
+ "PushNotifications": {
+ "presentationOptions": []
}
}
}
diff --git a/cloudflare/migrations/0002_mobile_push.sql b/cloudflare/migrations/0002_mobile_push.sql
new file mode 100644
index 0000000..8b10226
--- /dev/null
+++ b/cloudflare/migrations/0002_mobile_push.sql
@@ -0,0 +1,26 @@
+CREATE TABLE IF NOT EXISTS push_installations (
+ installation_id TEXT PRIMARY KEY,
+ management_secret_hash TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+CREATE TABLE IF NOT EXISTS push_devices (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ installation_id TEXT NOT NULL REFERENCES push_installations(installation_id) ON DELETE CASCADE,
+ recipient_id TEXT NOT NULL,
+ platform TEXT NOT NULL,
+ token_hash TEXT NOT NULL,
+ token_cipher TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ UNIQUE(installation_id,platform,token_hash)
+);
+CREATE INDEX IF NOT EXISTS idx_push_devices_recipient ON push_devices(installation_id,recipient_id);
+CREATE TABLE IF NOT EXISTS push_deliveries (
+ installation_id TEXT NOT NULL REFERENCES push_installations(installation_id) ON DELETE CASCADE,
+ idempotency_key TEXT NOT NULL,
+ recipient_id TEXT NOT NULL,
+ delivered_count INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ PRIMARY KEY (installation_id,idempotency_key)
+);
diff --git a/cloudflare/schema.sql b/cloudflare/schema.sql
index 4480b45..a689941 100644
--- a/cloudflare/schema.sql
+++ b/cloudflare/schema.sql
@@ -36,3 +36,30 @@ CREATE TABLE IF NOT EXISTS feedback_attachments (
data TEXT NOT NULL,
created_at INTEGER NOT NULL
);
+
+CREATE TABLE IF NOT EXISTS push_installations (
+ installation_id TEXT PRIMARY KEY,
+ management_secret_hash TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+CREATE TABLE IF NOT EXISTS push_devices (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ installation_id TEXT NOT NULL REFERENCES push_installations(installation_id) ON DELETE CASCADE,
+ recipient_id TEXT NOT NULL,
+ platform TEXT NOT NULL,
+ token_hash TEXT NOT NULL,
+ token_cipher TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ UNIQUE(installation_id,platform,token_hash)
+);
+CREATE INDEX IF NOT EXISTS idx_push_devices_recipient ON push_devices(installation_id,recipient_id);
+CREATE TABLE IF NOT EXISTS push_deliveries (
+ installation_id TEXT NOT NULL REFERENCES push_installations(installation_id) ON DELETE CASCADE,
+ idempotency_key TEXT NOT NULL,
+ recipient_id TEXT NOT NULL,
+ delivered_count INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ PRIMARY KEY (installation_id,idempotency_key)
+);
diff --git a/cloudflare/src/worker.ts b/cloudflare/src/worker.ts
index 3c281df..8f45294 100644
--- a/cloudflare/src/worker.ts
+++ b/cloudflare/src/worker.ts
@@ -5,7 +5,12 @@ interface Env {
CLOUDFLARE_RUNTIME_TOKEN: string;
PROVISION_LIMIT?: RateLimit;
FEEDBACK_LIMIT?: RateLimit;
+ PUSH_LIMIT?: RateLimit;
FEEDBACK_ADMIN_TOKEN?: string;
+ PUSH_DEVICE_ENCRYPTION_KEY?: string;
+ APNS_TEAM_ID?: string;
+ APNS_KEY_ID?: string;
+ APNS_PRIVATE_KEY?: string;
}
type WorkspaceRow = {
@@ -20,6 +25,8 @@ type WorkspaceRow = {
enabled: number;
error: string;
};
+type PushInstallationRow = { installation_id: string };
+type PushDeviceRow = { id: number; installation_id: string; recipient_id: string; platform: string; token_cipher: string; token_hash: string };
const BETA_WORKSPACE_LIMIT = 1000;
const RESERVED = new Set([
@@ -33,7 +40,7 @@ const json = (body: unknown, status = 200): Response => new Response(JSON.string
"cache-control": "no-store",
"access-control-allow-origin": "*",
"access-control-allow-headers": "authorization, content-type",
- "access-control-allow-methods": "GET, POST, OPTIONS",
+ "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
},
});
const slugValid = (slug: string): boolean => /^(?=.{3,48}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])$/.test(slug) && !RESERVED.has(slug);
@@ -64,6 +71,146 @@ const unseal = async (secret: string, value: string): Promise => {
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(ivText) }, key, fromBase64(encryptedText));
return new TextDecoder().decode(decrypted);
};
+const base64url = (value: Uint8Array | string): string => {
+ const bytesValue = typeof value === "string" ? new TextEncoder().encode(value) : value;
+ return base64(bytesValue).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
+};
+const pemBytes = (value: string): Uint8Array => {
+ const body = value.replace(/\\n/g, "\n").replace(/-----BEGIN [^-]+-----|-----END [^-]+-----|\s/g, "");
+ const decoded = atob(body);
+ const result = new Uint8Array(new ArrayBuffer(decoded.length));
+ for (let index = 0; index < decoded.length; index++) result[index] = decoded.charCodeAt(index);
+ return result;
+};
+
+let apnsAuthorization: { value: string; created: number } | null = null;
+async function apnsToken(env: Env): Promise {
+ if (!env.APNS_TEAM_ID || !env.APNS_KEY_ID || !env.APNS_PRIVATE_KEY) throw new Error("APNs delivery is not configured.");
+ const timestamp = Math.floor(Date.now() / 1000);
+ if (apnsAuthorization && timestamp - apnsAuthorization.created < 45 * 60) return apnsAuthorization.value;
+ const header = base64url(JSON.stringify({ alg: "ES256", kid: env.APNS_KEY_ID }));
+ const claims = base64url(JSON.stringify({ iss: env.APNS_TEAM_ID, iat: timestamp }));
+ const key = await crypto.subtle.importKey("pkcs8", pemBytes(env.APNS_PRIVATE_KEY), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
+ const signature = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, new TextEncoder().encode(`${header}.${claims}`)));
+ apnsAuthorization = { value: `${header}.${claims}.${base64url(signature)}`, created: timestamp };
+ return apnsAuthorization.value;
+}
+
+async function authenticatedPushInstallation(request: Request, env: Env, installationId: string): Promise {
+ const secret = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") || "";
+ if (!secret || !/^[a-f0-9]{16}$/.test(installationId)) return null;
+ const row = await env.REGISTRY.prepare("SELECT * FROM push_installations WHERE installation_id=?").bind(installationId).first();
+ return row && await sha256(secret) === row.management_secret_hash ? row : null;
+}
+
+async function registerPushInstallation(request: Request, env: Env): Promise {
+ const clientAddress = request.headers.get("cf-connecting-ip") || "unknown";
+ if (env.PUSH_LIMIT && !(await env.PUSH_LIMIT.limit({ key: `install:${clientAddress}` })).success) return json({ error: "Too many phone-notification registration attempts. Try again shortly." }, 429);
+ const body = await request.json().catch(() => ({})) as Record;
+ const installationId = String(body.installation_id || "");
+ const secret = String(body.management_secret || "");
+ if (!/^[a-f0-9]{16}$/.test(installationId) || secret.length < 32) return json({ error: "This 1Helm installation could not be verified." }, 400);
+ const hashed = await sha256(secret);
+ const existing = await env.REGISTRY.prepare("SELECT * FROM push_installations WHERE installation_id=?").bind(installationId).first();
+ if (existing && (existing as PushInstallationRow & { management_secret_hash?: string }).management_secret_hash !== hashed) return json({ error: "This installation is owned by different push credentials." }, 409);
+ if (!existing) await env.REGISTRY.prepare("INSERT INTO push_installations (installation_id,management_secret_hash,created_at,updated_at) VALUES (?,?,?,?)").bind(installationId, hashed, Date.now(), Date.now()).run();
+ else await env.REGISTRY.prepare("UPDATE push_installations SET updated_at=? WHERE installation_id=?").bind(Date.now(), installationId).run();
+ return json({ ok: true });
+}
+
+async function pushDevice(request: Request, env: Env): Promise {
+ const body = await request.json().catch(() => ({})) as Record;
+ const installationId = String(body.installation_id || "");
+ const clientAddress = request.headers.get("cf-connecting-ip") || "unknown";
+ if (env.PUSH_LIMIT && !(await env.PUSH_LIMIT.limit({ key: `device:${installationId}:${clientAddress}` })).success) return json({ error: "Too many phone-notification device requests. Try again shortly." }, 429);
+ if (!await authenticatedPushInstallation(request, env, installationId)) return json({ error: "Push authorization failed." }, 401);
+ if (!env.PUSH_DEVICE_ENCRYPTION_KEY) return json({ error: "Push device storage is not configured." }, 503);
+ const recipientId = String(body.recipient_id || "");
+ const platform = String(body.platform || "");
+ const token = String(body.token || "").trim();
+ if (!/^[a-f0-9]{32}$/.test(recipientId) || !["ios", "android"].includes(platform) || !/^[A-Za-z0-9:_-]{32,4096}$/.test(token)) return json({ error: "Invalid push device." }, 400);
+ const tokenHash = await sha256(`${platform}:${token}`);
+ if (request.method === "DELETE") {
+ await env.REGISTRY.prepare("DELETE FROM push_devices WHERE installation_id=? AND recipient_id=? AND platform=? AND token_hash=?").bind(installationId, recipientId, platform, tokenHash).run();
+ return json({ ok: true });
+ }
+ const cipher = await seal(env.PUSH_DEVICE_ENCRYPTION_KEY, token);
+ const timestamp = Date.now();
+ await env.REGISTRY.prepare(`INSERT INTO push_devices (installation_id,recipient_id,platform,token_hash,token_cipher,created_at,updated_at)
+ VALUES (?,?,?,?,?,?,?) ON CONFLICT(installation_id,platform,token_hash) DO UPDATE SET recipient_id=excluded.recipient_id,token_cipher=excluded.token_cipher,updated_at=excluded.updated_at`).bind(
+ installationId, recipientId, platform, tokenHash, cipher, timestamp, timestamp,
+ ).run();
+ return json({ ok: true });
+}
+
+async function sendApns(env: Env, device: PushDeviceRow, notification: Record): Promise<{ delivered: boolean; permanent: boolean; reason: string }> {
+ if (!env.PUSH_DEVICE_ENCRYPTION_KEY) throw new Error("Push device storage is not configured.");
+ const token = await unseal(env.PUSH_DEVICE_ENCRYPTION_KEY, device.token_cipher);
+ const authorization = await apnsToken(env);
+ const alert = { title: String(notification.title || "1Helm").slice(0, 178), body: String(notification.body || "New activity").slice(0, 512) };
+ const aps: Record = { alert, "thread-id": `channel-${Number(notification.channelId || 0)}`, "interruption-level": "active" };
+ if (notification.sound !== false) aps.sound = "default";
+ const payload = JSON.stringify({
+ aps,
+ channelId: Number(notification.channelId || 0),
+ messageId: Number(notification.messageId || 0),
+ rootMessageId: Number(notification.rootMessageId || 0) || null,
+ });
+ const send = async (host: string): Promise => fetch(`https://${host}/3/device/${encodeURIComponent(token)}`, {
+ method: "POST",
+ headers: {
+ authorization: `bearer ${authorization}`,
+ "apns-topic": "com.gitcommit90.onehelm.mobile",
+ "apns-push-type": "alert",
+ "apns-priority": "10",
+ "apns-collapse-id": String(notification.idempotency_key || `message-${notification.messageId || "new"}`).slice(0, 64),
+ },
+ body: payload,
+ });
+ let response = await send("api.push.apple.com");
+ let result = await response.json().catch(() => ({})) as { reason?: string };
+ if (!response.ok && result.reason === "BadDeviceToken") {
+ response = await send("api.sandbox.push.apple.com");
+ result = await response.json().catch(() => ({})) as { reason?: string };
+ }
+ const reason = String(result.reason || (response.ok ? "" : `HTTP ${response.status}`));
+ return { delivered: response.ok, permanent: response.status === 410 || ["BadDeviceToken", "DeviceTokenNotForTopic", "Unregistered"].includes(reason), reason };
+}
+
+async function pushDelivery(request: Request, env: Env): Promise {
+ const body = await request.json().catch(() => ({})) as Record;
+ const installationId = String(body.installation_id || "");
+ if (env.PUSH_LIMIT && !(await env.PUSH_LIMIT.limit({ key: `delivery:${installationId}` })).success) return json({ error: "Phone-notification delivery is temporarily rate limited." }, 429);
+ if (!await authenticatedPushInstallation(request, env, installationId)) return json({ error: "Push authorization failed." }, 401);
+ const recipientId = String(body.recipient_id || "");
+ const idempotencyKey = String(body.idempotency_key || "").slice(0, 200);
+ if (!/^[a-f0-9]{32}$/.test(recipientId) || !idempotencyKey) return json({ error: "Invalid push delivery." }, 400);
+ const claimed = await env.REGISTRY.prepare(`INSERT OR IGNORE INTO push_deliveries
+ (installation_id,idempotency_key,recipient_id,delivered_count,created_at) VALUES (?,?,?,-1,?)`).bind(installationId, idempotencyKey, recipientId, Date.now()).run();
+ if (!Number(claimed.meta.changes || 0)) {
+ const previous = await env.REGISTRY.prepare("SELECT delivered_count FROM push_deliveries WHERE installation_id=? AND idempotency_key=?").bind(installationId, idempotencyKey).first<{ delivered_count: number }>();
+ return json({ delivered: Math.max(0, Number(previous?.delivered_count || 0)), existing: true });
+ }
+ const results = await env.REGISTRY.prepare("SELECT * FROM push_devices WHERE installation_id=? AND recipient_id=? ORDER BY id").bind(installationId, recipientId).all();
+ const devices = results.results || [];
+ let delivered = 0;
+ const errors: string[] = [];
+ for (const device of devices) {
+ if (device.platform !== "ios") { errors.push("Android delivery is not configured."); continue; }
+ try {
+ const outcome = await sendApns(env, device, { ...body, idempotency_key: idempotencyKey });
+ if (outcome.delivered) delivered += 1;
+ else if (outcome.reason) errors.push(outcome.reason);
+ if (outcome.permanent) await env.REGISTRY.prepare("DELETE FROM push_devices WHERE id=?").bind(device.id).run();
+ } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); }
+ }
+ if (devices.length && delivered === 0 && errors.length) {
+ await env.REGISTRY.prepare("DELETE FROM push_deliveries WHERE installation_id=? AND idempotency_key=? AND delivered_count=-1").bind(installationId, idempotencyKey).run();
+ return json({ error: errors.join("; ").slice(0, 500) }, 502);
+ }
+ await env.REGISTRY.prepare("UPDATE push_deliveries SET delivered_count=? WHERE installation_id=? AND idempotency_key=?").bind(delivered, installationId, idempotencyKey).run();
+ return json({ delivered, devices: devices.length });
+}
async function cf(env: Env, path: string, init: RequestInit = {}): Promise {
const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
@@ -223,6 +370,9 @@ export default {
if (url.pathname === "/health") return json({ ok: true });
if (url.pathname === "/v1/feedback" && request.method === "POST") return feedbackIntake(request, env);
if (url.pathname === "/v1/feedback" && request.method === "GET") return feedbackInbox(request, env);
+ if (url.pathname === "/v1/push/installations" && request.method === "POST") return registerPushInstallation(request, env);
+ if (url.pathname === "/v1/push/devices" && ["POST", "DELETE"].includes(request.method)) return pushDevice(request, env);
+ if (url.pathname === "/v1/push/deliveries" && request.method === "POST") return pushDelivery(request, env);
const availability = url.pathname.match(/^\/v1\/slugs\/([a-z0-9-]+)$/);
if (availability && request.method === "GET") {
const slug = availability[1].toLowerCase();
diff --git a/cloudflare/wrangler.jsonc b/cloudflare/wrangler.jsonc
index 07bedc6..82232c9 100644
--- a/cloudflare/wrangler.jsonc
+++ b/cloudflare/wrangler.jsonc
@@ -16,6 +16,11 @@
"name": "FEEDBACK_LIMIT",
"namespace_id": "1002",
"simple": { "limit": 30, "period": 60 }
+ },
+ {
+ "name": "PUSH_LIMIT",
+ "namespace_id": "1003",
+ "simple": { "limit": 120, "period": 60 }
}
],
"d1_databases": [
diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj
index 063e45e..9155699 100644
--- a/ios/App/App.xcodeproj/project.pbxproj
+++ b/ios/App/App.xcodeproj/project.pbxproj
@@ -298,16 +298,18 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
buildSettings = {
+ APNS_ENVIRONMENT = development;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 15;
+ CURRENT_PROJECT_VERSION = 16;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.0.15;
+ MARKETING_VERSION = 0.0.16;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.gitcommit90.onehelm.mobile;
PRODUCT_NAME = 1Helm;
@@ -320,16 +322,18 @@
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
+ APNS_ENVIRONMENT = production;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 15;
+ CURRENT_PROJECT_VERSION = 16;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.0.15;
+ MARKETING_VERSION = 0.0.16;
PRODUCT_BUNDLE_IDENTIFIER = com.gitcommit90.onehelm.mobile;
PRODUCT_NAME = 1Helm;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
diff --git a/ios/App/App/App.entitlements b/ios/App/App/App.entitlements
new file mode 100644
index 0000000..c8c412e
--- /dev/null
+++ b/ios/App/App/App.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ aps-environment
+ $(APNS_ENVIRONMENT)
+
+
diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift
index c3cd83b..01adc5c 100644
--- a/ios/App/App/AppDelegate.swift
+++ b/ios/App/App/AppDelegate.swift
@@ -11,6 +11,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return true
}
+ func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)
+ }
+
+ func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
+ NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
+ }
+
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist
index 8d25952..1d0d5a1 100644
--- a/ios/App/App/Info.plist
+++ b/ios/App/App/Info.plist
@@ -39,8 +39,14 @@
ITSAppUsesNonExemptEncryption
+ NSCameraUsageDescription
+ 1Helm uses the camera only when you choose Take Photo or Video for a message attachment.
NSMicrophoneUsageDescription
- 1Helm uses the microphone only when you choose dictation in a message composer.
+ 1Helm uses the microphone only when you choose dictation or record a video attachment.
+ NSPhotoLibraryUsageDescription
+ 1Helm accesses photos and videos only when you choose them as message attachments.
+ NSPhotoLibraryAddUsageDescription
+ 1Helm saves media to your photo library only when you explicitly choose to do so.
NSSpeechRecognitionUsageDescription
1Helm converts speech to text only when you choose dictation in a message composer.
UILaunchStoryboardName
diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift
index d6337be..dc8ed3c 100644
--- a/ios/App/CapApp-SPM/Package.swift
+++ b/ios/App/CapApp-SPM/Package.swift
@@ -16,6 +16,7 @@ let package = Package(
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
.package(name: "CapacitorBrowser", path: "../../../node_modules/@capacitor/browser"),
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
+ .package(name: "CapacitorPushNotifications", path: "../../../node_modules/@capacitor/push-notifications"),
.package(name: "CapacitorSplashScreen", path: "../../../node_modules/@capacitor/splash-screen"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
],
@@ -29,6 +30,7 @@ let package = Package(
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorBrowser", package: "CapacitorBrowser"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
+ .product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"),
.product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
]
diff --git a/package-lock.json b/package-lock.json
index d1b207b..b58d8e1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "1helm",
- "version": "0.0.15",
+ "version": "0.0.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "1helm",
- "version": "0.0.15",
+ "version": "0.0.16",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
@@ -17,6 +17,7 @@
"@capacitor/core": "^8.4.2",
"@capacitor/ios": "^8.4.2",
"@capacitor/keyboard": "^8.0.1",
+ "@capacitor/push-notifications": "^8.0.1",
"@capacitor/splash-screen": "^8.0.1",
"@capacitor/status-bar": "^8.0.1",
"@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz",
@@ -232,6 +233,15 @@
"@capacitor/core": ">=8.0.0"
}
},
+ "node_modules/@capacitor/push-notifications": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.0.1.tgz",
+ "integrity": "sha512-CDY6syMxdG81Epn+V4fCYRR4L9tVS72F1kVrTY2ka6umWy4BmwXOXPsxGxlE5PmHLOhmBwKpy3uOiIJtSpMhOw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@capacitor/core": ">=8.0.0"
+ }
+ },
"node_modules/@capacitor/splash-screen": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-8.0.1.tgz",
diff --git a/package.json b/package.json
index 4f14500..44e4e70 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "1helm",
"productName": "1Helm",
- "version": "0.0.15",
+ "version": "0.0.16",
"private": true,
"type": "module",
"license": "AGPL-3.0-only",
@@ -62,6 +62,7 @@
"@capacitor/core": "^8.4.2",
"@capacitor/ios": "^8.4.2",
"@capacitor/keyboard": "^8.0.1",
+ "@capacitor/push-notifications": "^8.0.1",
"@capacitor/splash-screen": "^8.0.1",
"@capacitor/status-bar": "^8.0.1",
"@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz",
diff --git a/public/index.html b/public/index.html
index b7d5a91..4373f30 100644
--- a/public/index.html
+++ b/public/index.html
@@ -29,11 +29,11 @@
document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); });
})();
-
+
-
+