mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
fix(native): fix widget image management and add tests
- Fix `copyBundledAsset` to accept a URI instead of a named bundle asset, resolving via `resolveAssetURL` with support for remote downloads - Read app group identifier from Info.plist (`ExpoWidgetsAppGroupIdentifier`) instead of a hardcoded string - Add `pruneWidgetImages(maxAgeSeconds)` to age-based cache cleanup, skipping the widget icon file - Add `normalizedFileName` helper to sanitize cache keys and a `log` helper with consistent prefix - Extract `getWidgetIconAsset()` into `widget-assets.ts` so the asset require is testable in isolation - Add Vitest config for the native app and unit tests covering widget data-fetch, image download, and prune logic
This commit is contained in:
@@ -12,11 +12,12 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
claude-review:
|
claude-review:
|
||||||
# Optional: Filter by PR author
|
# Skip for bot PRs and fork PRs due to OIDC token limitations in pull_request_target
|
||||||
# if: |
|
# Fork PRs fail because the actor in OIDC context is the fork contributor (read-only)
|
||||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
if: |
|
||||||
# github.event.pull_request.user.login == 'new-developer' ||
|
github.event.pull_request.draft == false &&
|
||||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
github.event.pull_request.user.type != 'Bot' &&
|
||||||
|
github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
@@ -41,3 +42,7 @@ jobs:
|
|||||||
prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
|
prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||||
|
track_progress: true
|
||||||
|
claude_args: |
|
||||||
|
--model 'claude-opus-4-6'
|
||||||
|
--allowedTools 'mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)'
|
||||||
|
|||||||
@@ -46,4 +46,4 @@ jobs:
|
|||||||
# Optional: Add claude_args to customize behavior and configuration
|
# Optional: Add claude_args to customize behavior and configuration
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
claude_args: "--model claude-opus-4-6"
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ export async function downloadWidgetImage(url: string, key: string): Promise<str
|
|||||||
return getModule().downloadWidgetImage(url, key);
|
return getModule().downloadWidgetImage(url, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyBundledAsset(assetName: string, key: string): Promise<string | null> {
|
export async function copyBundledAsset(assetUri: string, key: string): Promise<string | null> {
|
||||||
if (Platform.OS !== "ios") return null;
|
if (Platform.OS !== "ios") return null;
|
||||||
return getModule().copyBundledAsset(assetName, key);
|
return getModule().copyBundledAsset(assetUri, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearWidgetImages(): Promise<void> {
|
export async function clearWidgetImages(): Promise<void> {
|
||||||
if (Platform.OS !== "ios") return;
|
if (Platform.OS !== "ios") return;
|
||||||
return getModule().clearWidgetImages();
|
return getModule().clearWidgetImages();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pruneWidgetImages(maxAgeSeconds: number): Promise<void> {
|
||||||
|
if (Platform.OS !== "ios") return;
|
||||||
|
return getModule().pruneWidgetImages(maxAgeSeconds);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
private let groupIdentifier = "group.com.jakejarvis.sofa"
|
|
||||||
private let imageDirectory = "widget_images"
|
private let imageDirectory = "widget_images"
|
||||||
|
private let widgetIconKey = "sofa_icon.png"
|
||||||
|
private let infoPlistAppGroupKey = "ExpoWidgetsAppGroupIdentifier"
|
||||||
|
private let logPrefix = "[SofaWidgetsSupport]"
|
||||||
|
|
||||||
public class SofaWidgetsSupportModule: Module {
|
public class SofaWidgetsSupportModule: Module {
|
||||||
public func definition() -> ModuleDefinition {
|
public func definition() -> ModuleDefinition {
|
||||||
@@ -10,20 +13,20 @@ public class SofaWidgetsSupportModule: Module {
|
|||||||
|
|
||||||
AsyncFunction("downloadWidgetImage") { (url: String, key: String) -> String? in
|
AsyncFunction("downloadWidgetImage") { (url: String, key: String) -> String? in
|
||||||
guard let imageUrl = URL(string: url) else {
|
guard let imageUrl = URL(string: url) else {
|
||||||
|
self.log("Invalid widget image URL: \(url)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let directoryUrl = self.ensureImageDirectory() else {
|
guard let destinationUrl = self.destinationURL(for: key) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let destinationUrl = directoryUrl.appendingPathComponent(key)
|
|
||||||
|
|
||||||
let (data, response) = try await URLSession.shared.data(from: imageUrl)
|
let (data, response) = try await URLSession.shared.data(from: imageUrl)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse,
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
httpResponse.statusCode == 200
|
httpResponse.statusCode == 200
|
||||||
else {
|
else {
|
||||||
|
self.log("Failed to download widget image: \(url)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,46 +46,97 @@ public class SofaWidgetsSupportModule: Module {
|
|||||||
return destinationUrl.absoluteString
|
return destinationUrl.absoluteString
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("copyBundledAsset") { (assetName: String, key: String) -> String? in
|
AsyncFunction("copyBundledAsset") { (assetUri: String, key: String) -> String? in
|
||||||
guard let directoryUrl = self.ensureImageDirectory() else {
|
guard let destinationUrl = self.destinationURL(for: key) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let destinationUrl = directoryUrl.appendingPathComponent(key)
|
|
||||||
|
|
||||||
// Skip if already copied
|
// Skip if already copied
|
||||||
if FileManager.default.fileExists(atPath: destinationUrl.path) {
|
if FileManager.default.fileExists(atPath: destinationUrl.path) {
|
||||||
return destinationUrl.absoluteString
|
return destinationUrl.absoluteString
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let image = UIImage(named: assetName) else {
|
guard let sourceUrl = self.resolveAssetURL(assetUri) else {
|
||||||
|
self.log("Unable to resolve widget asset URI: \(assetUri)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let pngData = image.pngData() else {
|
let data: Data
|
||||||
return nil
|
if sourceUrl.isFileURL {
|
||||||
|
data = try Data(contentsOf: sourceUrl)
|
||||||
|
} else {
|
||||||
|
let (downloadedData, response) = try await URLSession.shared.data(from: sourceUrl)
|
||||||
|
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
|
||||||
|
self.log("Failed to download widget asset: \(assetUri)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data = downloadedData
|
||||||
}
|
}
|
||||||
|
|
||||||
try pngData.write(to: destinationUrl, options: .atomic)
|
try data.write(to: destinationUrl, options: .atomic)
|
||||||
return destinationUrl.absoluteString
|
return destinationUrl.absoluteString
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncFunction("clearWidgetImages") {
|
AsyncFunction("clearWidgetImages") {
|
||||||
guard let containerUrl = FileManager.default.containerURL(
|
guard let directoryUrl = self.imageDirectoryURL() else {
|
||||||
forSecurityApplicationGroupIdentifier: groupIdentifier
|
|
||||||
) else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let directoryUrl = containerUrl.appendingPathComponent(imageDirectory)
|
|
||||||
try? FileManager.default.removeItem(at: directoryUrl)
|
try? FileManager.default.removeItem(at: directoryUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AsyncFunction("pruneWidgetImages") { (maxAgeSeconds: Double) in
|
||||||
|
guard maxAgeSeconds > 0 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let directoryUrl = self.imageDirectoryURL() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let cutoffDate = Date().addingTimeInterval(-maxAgeSeconds)
|
||||||
|
let resourceKeys: Set<URLResourceKey> = [.contentModificationDateKey]
|
||||||
|
|
||||||
|
let fileUrls = try fileManager.contentsOfDirectory(
|
||||||
|
at: directoryUrl,
|
||||||
|
includingPropertiesForKeys: Array(resourceKeys),
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
)
|
||||||
|
|
||||||
|
for fileUrl in fileUrls {
|
||||||
|
if fileUrl.lastPathComponent == widgetIconKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let modifiedAt = try fileUrl.resourceValues(forKeys: resourceKeys).contentModificationDate
|
||||||
|
?? .distantPast
|
||||||
|
if modifiedAt < cutoffDate {
|
||||||
|
try? fileManager.removeItem(at: fileUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ensureImageDirectory() -> URL? {
|
private func appGroupIdentifier() -> String? {
|
||||||
|
guard let identifier = Bundle.main.object(
|
||||||
|
forInfoDictionaryKey: infoPlistAppGroupKey
|
||||||
|
) as? String, !identifier.isEmpty else {
|
||||||
|
log("Missing \(infoPlistAppGroupKey) in Info.plist")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
private func imageDirectoryURL() -> URL? {
|
||||||
|
guard let groupIdentifier = appGroupIdentifier() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
guard let containerUrl = FileManager.default.containerURL(
|
guard let containerUrl = FileManager.default.containerURL(
|
||||||
forSecurityApplicationGroupIdentifier: groupIdentifier
|
forSecurityApplicationGroupIdentifier: groupIdentifier
|
||||||
) else {
|
) else {
|
||||||
|
log("Unable to access app group container: \(groupIdentifier)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +148,40 @@ public class SofaWidgetsSupportModule: Module {
|
|||||||
return directoryUrl
|
return directoryUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func destinationURL(for key: String) -> URL? {
|
||||||
|
guard let directoryUrl = imageDirectoryURL() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return directoryUrl.appendingPathComponent(normalizedFileName(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveAssetURL(_ assetUri: String) -> URL? {
|
||||||
|
if assetUri.isEmpty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let url = URL(string: assetUri), url.scheme != nil {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
return URL(fileURLWithPath: assetUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedFileName(_ key: String) -> String {
|
||||||
|
let fallback = UUID().uuidString
|
||||||
|
let source = key.isEmpty ? fallback : key
|
||||||
|
let allowedCharacters = CharacterSet.alphanumerics.union(
|
||||||
|
CharacterSet(charactersIn: "._-")
|
||||||
|
)
|
||||||
|
let normalized = source.components(separatedBy: allowedCharacters.inverted).joined(separator: "_")
|
||||||
|
return normalized.isEmpty ? fallback : normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
private func log(_ message: String) {
|
||||||
|
print("\(logPrefix) \(message)")
|
||||||
|
}
|
||||||
|
|
||||||
private func resizeImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
private func resizeImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
||||||
let size = image.size
|
let size = image.size
|
||||||
let scale = min(maxDimension / size.width, maxDimension / size.height, 1.0)
|
let scale = min(maxDimension / size.width, maxDimension / size.height, 1.0)
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
import { requireNativeModule } from "expo";
|
import { requireNativeModule } from "expo";
|
||||||
|
|
||||||
export default requireNativeModule("SofaWidgetsSupport");
|
type SofaWidgetsSupportModuleType = {
|
||||||
|
downloadWidgetImage(url: string, key: string): Promise<string | null>;
|
||||||
|
copyBundledAsset(assetUri: string, key: string): Promise<string | null>;
|
||||||
|
clearWidgetImages(): Promise<void>;
|
||||||
|
pruneWidgetImages(maxAgeSeconds: number): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default requireNativeModule<SofaWidgetsSupportModuleType>("SofaWidgetsSupport");
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"format": "oxfmt --config ../../.oxfmtrc.json",
|
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||||
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||||
"check-types": "tsc --noEmit"
|
"check-types": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/expo": "catalog:",
|
"@better-auth/expo": "catalog:",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function getWidgetIconAsset() {
|
||||||
|
return require("../../assets/widget/sofa-icon.png");
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
const CACHED_WIDGET_IMAGE_RE = /^file:\/\/\/group\/cw_/;
|
||||||
|
const platform = { OS: "ios" };
|
||||||
|
const resolveAssetSource = vi.fn(() => ({ uri: "file:///tmp/sofa-icon.png" }));
|
||||||
|
const continueWatching = vi.fn();
|
||||||
|
const upcoming = vi.fn();
|
||||||
|
const downloadWidgetImage = vi.fn(async (_url: string, key: string) => `file:///group/${key}`);
|
||||||
|
const copyBundledAsset = vi.fn(async (_assetUri: string, key: string) => `file:///group/${key}`);
|
||||||
|
const pruneWidgetImages = vi.fn(async () => undefined);
|
||||||
|
const getWidgetIconAsset = vi.fn(() => "widget-icon-asset");
|
||||||
|
|
||||||
|
const continueWatchingWidget = {
|
||||||
|
updateSnapshot: vi.fn(),
|
||||||
|
updateTimeline: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const upcomingWidget = {
|
||||||
|
updateSnapshot: vi.fn(),
|
||||||
|
updateTimeline: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("react-native", () => ({
|
||||||
|
Image: {
|
||||||
|
resolveAssetSource,
|
||||||
|
},
|
||||||
|
Platform: platform,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/widget-assets", () => ({
|
||||||
|
getWidgetIconAsset,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/orpc", () => ({
|
||||||
|
client: {
|
||||||
|
dashboard: {
|
||||||
|
continueWatching,
|
||||||
|
upcoming,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/server", () => ({
|
||||||
|
resolveUrl: (path: string | null) => (path ? `https://sofa.test${path}` : null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../modules/sofa-widgets-support", () => ({
|
||||||
|
copyBundledAsset,
|
||||||
|
downloadWidgetImage,
|
||||||
|
pruneWidgetImages,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/widgets/continue-watching", () => ({
|
||||||
|
default: continueWatchingWidget,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/widgets/upcoming", () => ({
|
||||||
|
default: upcomingWidget,
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function loadWidgetsModule() {
|
||||||
|
vi.resetModules();
|
||||||
|
return import("./widgets");
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||||
|
platform.OS = "ios";
|
||||||
|
continueWatching.mockResolvedValue({ items: [] });
|
||||||
|
upcoming.mockResolvedValue({ items: [] });
|
||||||
|
downloadWidgetImage.mockImplementation(
|
||||||
|
async (_url: string, key: string) => `file:///group/${key}`,
|
||||||
|
);
|
||||||
|
copyBundledAsset.mockImplementation(
|
||||||
|
async (_assetUri: string, key: string) => `file:///group/${key}`,
|
||||||
|
);
|
||||||
|
pruneWidgetImages.mockResolvedValue(undefined);
|
||||||
|
resolveAssetSource.mockReturnValue({ uri: "file:///tmp/sofa-icon.png" });
|
||||||
|
getWidgetIconAsset.mockReturnValue("widget-icon-asset");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("refreshWidgets", () => {
|
||||||
|
test("uses a fresh widget image path on each refresh cycle", async () => {
|
||||||
|
continueWatching.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
id: "title-1",
|
||||||
|
title: "Severance",
|
||||||
|
backdropPath: "/backdrop-1.jpg",
|
||||||
|
},
|
||||||
|
nextEpisode: {
|
||||||
|
seasonNumber: 2,
|
||||||
|
episodeNumber: 3,
|
||||||
|
stillPath: "/still-1.jpg",
|
||||||
|
},
|
||||||
|
watchedEpisodes: 2,
|
||||||
|
totalEpisodes: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { refreshWidgets } = await loadWidgetsModule();
|
||||||
|
|
||||||
|
await refreshWidgets();
|
||||||
|
const firstPath =
|
||||||
|
continueWatchingWidget.updateTimeline.mock.calls[0]?.[0]?.[0]?.props?.imageFilePath;
|
||||||
|
|
||||||
|
continueWatchingWidget.updateTimeline.mockClear();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
await refreshWidgets();
|
||||||
|
const secondPath =
|
||||||
|
continueWatchingWidget.updateTimeline.mock.calls[0]?.[0]?.[0]?.props?.imageFilePath;
|
||||||
|
|
||||||
|
expect(firstPath).toMatch(CACHED_WIDGET_IMAGE_RE);
|
||||||
|
expect(secondPath).toMatch(CACHED_WIDGET_IMAGE_RE);
|
||||||
|
expect(firstPath).not.toBe(secondPath);
|
||||||
|
expect(copyBundledAsset).toHaveBeenCalledWith("file:///tmp/sofa-icon.png", "sofa_icon.png");
|
||||||
|
expect(pruneWidgetImages).toHaveBeenCalledWith(21600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publishes a timeline even when one widget image download fails", async () => {
|
||||||
|
continueWatching.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
id: "title-1",
|
||||||
|
title: "The Pitt",
|
||||||
|
backdropPath: "/fallback.jpg",
|
||||||
|
},
|
||||||
|
nextEpisode: {
|
||||||
|
seasonNumber: 1,
|
||||||
|
episodeNumber: 5,
|
||||||
|
stillPath: "/bad.jpg",
|
||||||
|
},
|
||||||
|
watchedEpisodes: 4,
|
||||||
|
totalEpisodes: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
id: "title-2",
|
||||||
|
title: "Andor",
|
||||||
|
backdropPath: "/backdrop-2.jpg",
|
||||||
|
},
|
||||||
|
nextEpisode: {
|
||||||
|
seasonNumber: 2,
|
||||||
|
episodeNumber: 1,
|
||||||
|
stillPath: "/good.jpg",
|
||||||
|
},
|
||||||
|
watchedEpisodes: 8,
|
||||||
|
totalEpisodes: 12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadWidgetImage.mockImplementation(async (url: string, key: string) => {
|
||||||
|
if (url.includes("/bad.jpg")) {
|
||||||
|
throw new Error("download failed");
|
||||||
|
}
|
||||||
|
return `file:///group/${key}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { refreshWidgets } = await loadWidgetsModule();
|
||||||
|
await refreshWidgets();
|
||||||
|
|
||||||
|
const entries = continueWatchingWidget.updateTimeline.mock.calls[0]?.[0];
|
||||||
|
expect(continueWatchingWidget.updateTimeline).toHaveBeenCalledTimes(1);
|
||||||
|
expect(entries).toHaveLength(2);
|
||||||
|
expect(entries[0]?.props?.imageFilePath).toBe("");
|
||||||
|
expect(entries[1]?.props?.imageFilePath).toMatch(CACHED_WIDGET_IMAGE_RE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes the copied icon path into empty widget states", async () => {
|
||||||
|
const { refreshWidgets } = await loadWidgetsModule();
|
||||||
|
await refreshWidgets();
|
||||||
|
|
||||||
|
expect(continueWatchingWidget.updateSnapshot).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
iconFilePath: "file:///group/sofa_icon.png",
|
||||||
|
titleName: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(upcomingWidget.updateSnapshot).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
iconFilePath: "file:///group/sofa_icon.png",
|
||||||
|
titleName: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is a no-op outside iOS", async () => {
|
||||||
|
platform.OS = "android";
|
||||||
|
|
||||||
|
const { refreshWidgets } = await loadWidgetsModule();
|
||||||
|
await refreshWidgets();
|
||||||
|
|
||||||
|
expect(continueWatching).not.toHaveBeenCalled();
|
||||||
|
expect(upcoming).not.toHaveBeenCalled();
|
||||||
|
expect(downloadWidgetImage).not.toHaveBeenCalled();
|
||||||
|
expect(copyBundledAsset).not.toHaveBeenCalled();
|
||||||
|
expect(pruneWidgetImages).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+151
-65
@@ -1,20 +1,48 @@
|
|||||||
import { Platform } from "react-native";
|
import { Image, Platform } from "react-native";
|
||||||
|
|
||||||
import { client } from "@/lib/orpc";
|
import { client } from "@/lib/orpc";
|
||||||
import { resolveUrl } from "@/lib/server";
|
import { resolveUrl } from "@/lib/server";
|
||||||
|
import { getWidgetIconAsset } from "@/lib/widget-assets";
|
||||||
import type { ContinueWatchingProps } from "@/widgets/continue-watching";
|
import type { ContinueWatchingProps } from "@/widgets/continue-watching";
|
||||||
import type { UpcomingProps } from "@/widgets/upcoming";
|
import type { UpcomingProps } from "@/widgets/upcoming";
|
||||||
|
|
||||||
import { copyBundledAsset, downloadWidgetImage } from "../../modules/sofa-widgets-support";
|
import {
|
||||||
|
copyBundledAsset,
|
||||||
|
downloadWidgetImage,
|
||||||
|
pruneWidgetImages,
|
||||||
|
} from "../../modules/sofa-widgets-support";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip null/undefined values from widget props before passing to the native module.
|
||||||
|
* iOS UserDefaults (used by expo-widgets to store timeline entries) does not support
|
||||||
|
* NSNull — any null or undefined value in the props dictionary will throw an ObjC
|
||||||
|
* NSInvalidArgumentException that surfaces as "Exception in HostFunction: <unknown>".
|
||||||
|
*/
|
||||||
|
function sanitizeProps<T extends object>(props: { [K in keyof T]: T[K] | null }): T {
|
||||||
|
const clean = {} as Record<string, unknown>;
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
if (value != null) {
|
||||||
|
clean[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clean as T;
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve a potentially-relative image path to an absolute URL and download it. */
|
/** Resolve a potentially-relative image path to an absolute URL and download it. */
|
||||||
async function downloadImage(path: string | null, key: string): Promise<string | null> {
|
async function downloadImage(path: string | null, key: string): Promise<string> {
|
||||||
const url = resolveUrl(path);
|
const url = resolveUrl(path);
|
||||||
if (!url) return null;
|
if (!url) return "";
|
||||||
return downloadWidgetImage(url, key);
|
try {
|
||||||
|
return (await downloadWidgetImage(url, key)) ?? "";
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Widgets] Failed to cache image (${key}):`, error);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIMELINE_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes (iOS minimum)
|
const TIMELINE_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes (iOS minimum)
|
||||||
|
const IMAGE_PRUNE_MAX_AGE_SECONDS = 6 * 60 * 60;
|
||||||
|
const WIDGET_ICON_KEY = "sofa_icon.png";
|
||||||
|
|
||||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
|
||||||
@@ -38,13 +66,51 @@ function formatEpisodeLabel(item: {
|
|||||||
return "TV";
|
return "TV";
|
||||||
}
|
}
|
||||||
|
|
||||||
let iconFilePath: string | null = null;
|
let refreshSequence = 0;
|
||||||
|
|
||||||
async function ensureIcon(): Promise<string | null> {
|
function hashString(value: string): string {
|
||||||
if (!iconFilePath) {
|
let hash = 0x811c9dc5;
|
||||||
iconFilePath = await copyBundledAsset("sofa-icon", "sofa_icon");
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
|
hash ^= value.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 0x01000193);
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextRefreshToken(prefix: string): string {
|
||||||
|
refreshSequence += 1;
|
||||||
|
return `${prefix}_${Date.now().toString(36)}_${refreshSequence.toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildImageKey(
|
||||||
|
prefix: string,
|
||||||
|
refreshToken: string,
|
||||||
|
parts: Array<string | number | null | undefined>,
|
||||||
|
): string {
|
||||||
|
const identity = parts
|
||||||
|
.filter((part): part is string | number => part != null && part !== "")
|
||||||
|
.join("|");
|
||||||
|
return `${prefix}_${refreshToken}_${hashString(identity)}.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWidgetIconUri(): string | null {
|
||||||
|
const source = Image.resolveAssetSource(getWidgetIconAsset());
|
||||||
|
return source?.uri ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureIcon(): Promise<string> {
|
||||||
|
const iconUri = resolveWidgetIconUri();
|
||||||
|
if (!iconUri) {
|
||||||
|
console.warn("[Widgets] Failed to resolve widget icon asset URI");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await copyBundledAsset(iconUri, WIDGET_ICON_KEY)) ?? "";
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Widgets] Failed to cache widget icon:", error);
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
return iconFilePath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshWidgets(): Promise<void> {
|
export async function refreshWidgets(): Promise<void> {
|
||||||
@@ -56,57 +122,69 @@ export async function refreshWidgets(): Promise<void> {
|
|||||||
import("@/widgets/upcoming"),
|
import("@/widgets/upcoming"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await ensureIcon();
|
const iconFilePath = await ensureIcon();
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refreshContinueWatching(ContinueWatchingWidget),
|
refreshContinueWatching(ContinueWatchingWidget, iconFilePath),
|
||||||
refreshUpcoming(UpcomingWidget),
|
refreshUpcoming(UpcomingWidget, iconFilePath),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pruneWidgetImages(IMAGE_PRUNE_MAX_AGE_SECONDS);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Widgets] Failed to prune cached images:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshContinueWatching(
|
async function refreshContinueWatching(
|
||||||
widget: Awaited<typeof import("@/widgets/continue-watching")>["default"],
|
widget: Awaited<typeof import("@/widgets/continue-watching")>["default"],
|
||||||
|
iconFilePath: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { items } = await client.dashboard.continueWatching();
|
const { items } = await client.dashboard.continueWatching();
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
widget.updateSnapshot({
|
widget.updateSnapshot(
|
||||||
isEmpty: true,
|
sanitizeProps<ContinueWatchingProps>({
|
||||||
iconFilePath,
|
iconFilePath,
|
||||||
titleId: "",
|
titleId: "",
|
||||||
titleName: "",
|
titleName: "",
|
||||||
imageFilePath: null,
|
imageFilePath: "",
|
||||||
watchedEpisodes: 0,
|
watchedEpisodes: 0,
|
||||||
totalEpisodes: 0,
|
totalEpisodes: 0,
|
||||||
isMovie: false,
|
isMovie: false,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const top5 = items.slice(0, 5);
|
const top5 = items.slice(0, 5);
|
||||||
|
const refreshToken = nextRefreshToken("cw");
|
||||||
|
|
||||||
const entries = await Promise.all(
|
const entries = await Promise.all(
|
||||||
top5.map(async (item, index) => {
|
top5.map(async (item, index) => {
|
||||||
const imageKey = `cw_${index}`;
|
|
||||||
const imagePath = item.nextEpisode?.stillPath ?? item.title.backdropPath;
|
const imagePath = item.nextEpisode?.stillPath ?? item.title.backdropPath;
|
||||||
|
const imageKey = buildImageKey("cw", refreshToken, [
|
||||||
|
item.title.id,
|
||||||
|
item.nextEpisode?.seasonNumber,
|
||||||
|
item.nextEpisode?.episodeNumber,
|
||||||
|
imagePath,
|
||||||
|
index,
|
||||||
|
]);
|
||||||
const imageFilePath = await downloadImage(imagePath, imageKey);
|
const imageFilePath = await downloadImage(imagePath, imageKey);
|
||||||
|
|
||||||
const props: ContinueWatchingProps = {
|
|
||||||
titleId: item.title.id,
|
|
||||||
titleName: item.title.title,
|
|
||||||
imageFilePath,
|
|
||||||
iconFilePath: null,
|
|
||||||
seasonNumber: item.nextEpisode?.seasonNumber,
|
|
||||||
episodeNumber: item.nextEpisode?.episodeNumber,
|
|
||||||
watchedEpisodes: item.watchedEpisodes,
|
|
||||||
totalEpisodes: item.totalEpisodes,
|
|
||||||
isMovie: !item.nextEpisode,
|
|
||||||
isEmpty: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: new Date(Date.now() + index * TIMELINE_INTERVAL_MS),
|
date: new Date(Date.now() + index * TIMELINE_INTERVAL_MS),
|
||||||
props,
|
props: sanitizeProps<ContinueWatchingProps>({
|
||||||
|
titleId: item.title.id,
|
||||||
|
titleName: item.title.title,
|
||||||
|
imageFilePath,
|
||||||
|
iconFilePath,
|
||||||
|
seasonNumber: item.nextEpisode?.seasonNumber,
|
||||||
|
episodeNumber: item.nextEpisode?.episodeNumber,
|
||||||
|
watchedEpisodes: item.watchedEpisodes,
|
||||||
|
totalEpisodes: item.totalEpisodes,
|
||||||
|
isMovie: !item.nextEpisode,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -119,6 +197,7 @@ async function refreshContinueWatching(
|
|||||||
|
|
||||||
async function refreshUpcoming(
|
async function refreshUpcoming(
|
||||||
widget: Awaited<typeof import("@/widgets/upcoming")>["default"],
|
widget: Awaited<typeof import("@/widgets/upcoming")>["default"],
|
||||||
|
iconFilePath: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { items } = await client.dashboard.upcoming({
|
const { items } = await client.dashboard.upcoming({
|
||||||
@@ -127,44 +206,51 @@ async function refreshUpcoming(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
widget.updateSnapshot({
|
widget.updateSnapshot(
|
||||||
isEmpty: true,
|
sanitizeProps<UpcomingProps>({
|
||||||
iconFilePath,
|
iconFilePath,
|
||||||
titleId: "",
|
titleId: "",
|
||||||
titleName: "",
|
titleName: "",
|
||||||
imageFilePath: null,
|
imageFilePath: "",
|
||||||
titleType: "tv",
|
titleType: "tv",
|
||||||
episodeCount: 0,
|
episodeCount: 0,
|
||||||
dateLabel: "",
|
dateLabel: "",
|
||||||
episodeLabel: "",
|
episodeLabel: "",
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const top5 = items.slice(0, 5);
|
const top5 = items.slice(0, 5);
|
||||||
|
const refreshToken = nextRefreshToken("up");
|
||||||
|
|
||||||
const entries = await Promise.all(
|
const entries = await Promise.all(
|
||||||
top5.map(async (item, index) => {
|
top5.map(async (item, index) => {
|
||||||
const imageKey = `up_${index}`;
|
const imageKey = buildImageKey("up", refreshToken, [
|
||||||
|
item.titleId,
|
||||||
|
item.date,
|
||||||
|
item.seasonNumber,
|
||||||
|
item.episodeNumber,
|
||||||
|
item.titleType,
|
||||||
|
item.backdropPath,
|
||||||
|
index,
|
||||||
|
]);
|
||||||
const imageFilePath = await downloadImage(item.backdropPath, imageKey);
|
const imageFilePath = await downloadImage(item.backdropPath, imageKey);
|
||||||
|
|
||||||
const props: UpcomingProps = {
|
|
||||||
titleId: item.titleId,
|
|
||||||
titleName: item.titleName,
|
|
||||||
imageFilePath,
|
|
||||||
iconFilePath: null,
|
|
||||||
titleType: item.titleType,
|
|
||||||
seasonNumber: item.seasonNumber,
|
|
||||||
episodeNumber: item.episodeNumber,
|
|
||||||
episodeCount: item.episodeCount,
|
|
||||||
dateLabel: formatShortDate(item.date),
|
|
||||||
episodeLabel: formatEpisodeLabel(item),
|
|
||||||
isEmpty: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: new Date(Date.now() + index * TIMELINE_INTERVAL_MS),
|
date: new Date(Date.now() + index * TIMELINE_INTERVAL_MS),
|
||||||
props,
|
props: sanitizeProps<UpcomingProps>({
|
||||||
|
titleId: item.titleId,
|
||||||
|
titleName: item.titleName,
|
||||||
|
imageFilePath,
|
||||||
|
iconFilePath,
|
||||||
|
titleType: item.titleType,
|
||||||
|
seasonNumber: item.seasonNumber,
|
||||||
|
episodeNumber: item.episodeNumber,
|
||||||
|
episodeCount: item.episodeCount,
|
||||||
|
dateLabel: formatShortDate(item.date),
|
||||||
|
episodeLabel: formatEpisodeLabel(item),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,20 +14,19 @@ import { createWidget, type WidgetEnvironment } from "expo-widgets";
|
|||||||
export type ContinueWatchingProps = {
|
export type ContinueWatchingProps = {
|
||||||
titleId: string;
|
titleId: string;
|
||||||
titleName: string;
|
titleName: string;
|
||||||
imageFilePath: string | null;
|
imageFilePath: string;
|
||||||
iconFilePath: string | null;
|
iconFilePath: string;
|
||||||
seasonNumber?: number;
|
seasonNumber?: number;
|
||||||
episodeNumber?: number;
|
episodeNumber?: number;
|
||||||
watchedEpisodes: number;
|
watchedEpisodes: number;
|
||||||
totalEpisodes: number;
|
totalEpisodes: number;
|
||||||
isMovie: boolean;
|
isMovie: boolean;
|
||||||
isEmpty: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnvironment) => {
|
const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnvironment) => {
|
||||||
"widget";
|
"widget";
|
||||||
|
|
||||||
if (props.isEmpty) {
|
if (!props.titleName) {
|
||||||
return (
|
return (
|
||||||
<VStack
|
<VStack
|
||||||
modifiers={[
|
modifiers={[
|
||||||
|
|||||||
@@ -14,21 +14,20 @@ import { createWidget, type WidgetEnvironment } from "expo-widgets";
|
|||||||
export type UpcomingProps = {
|
export type UpcomingProps = {
|
||||||
titleId: string;
|
titleId: string;
|
||||||
titleName: string;
|
titleName: string;
|
||||||
imageFilePath: string | null;
|
imageFilePath: string;
|
||||||
iconFilePath: string | null;
|
iconFilePath: string;
|
||||||
titleType: "movie" | "tv";
|
titleType: "movie" | "tv";
|
||||||
seasonNumber?: number | null;
|
seasonNumber?: number;
|
||||||
episodeNumber?: number | null;
|
episodeNumber?: number;
|
||||||
episodeCount: number;
|
episodeCount: number;
|
||||||
dateLabel: string;
|
dateLabel: string;
|
||||||
episodeLabel: string;
|
episodeLabel: string;
|
||||||
isEmpty: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const UpcomingWidget = (props: UpcomingProps, _env: WidgetEnvironment) => {
|
const UpcomingWidget = (props: UpcomingProps, _env: WidgetEnvironment) => {
|
||||||
"widget";
|
"widget";
|
||||||
|
|
||||||
if (props.isEmpty) {
|
if (!props.titleName) {
|
||||||
return (
|
return (
|
||||||
<VStack
|
<VStack
|
||||||
modifiers={[
|
modifiers={[
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { defineProject } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineProject({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user