mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 17:05: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:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
# Skip for bot PRs and fork PRs due to OIDC token limitations in pull_request_target
|
||||
# Fork PRs fail because the actor in OIDC context is the fork contributor (read-only)
|
||||
if: |
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.user.type != 'Bot' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -41,3 +42,7 @@ jobs:
|
||||
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
|
||||
# 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
|
||||
# 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
|
||||
# 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);
|
||||
}
|
||||
|
||||
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;
|
||||
return getModule().copyBundledAsset(assetName, key);
|
||||
return getModule().copyBundledAsset(assetUri, key);
|
||||
}
|
||||
|
||||
export async function clearWidgetImages(): Promise<void> {
|
||||
if (Platform.OS !== "ios") return;
|
||||
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 Foundation
|
||||
import UIKit
|
||||
|
||||
private let groupIdentifier = "group.com.jakejarvis.sofa"
|
||||
private let imageDirectory = "widget_images"
|
||||
private let widgetIconKey = "sofa_icon.png"
|
||||
private let infoPlistAppGroupKey = "ExpoWidgetsAppGroupIdentifier"
|
||||
private let logPrefix = "[SofaWidgetsSupport]"
|
||||
|
||||
public class SofaWidgetsSupportModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
@@ -10,20 +13,20 @@ public class SofaWidgetsSupportModule: Module {
|
||||
|
||||
AsyncFunction("downloadWidgetImage") { (url: String, key: String) -> String? in
|
||||
guard let imageUrl = URL(string: url) else {
|
||||
self.log("Invalid widget image URL: \(url)")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let directoryUrl = self.ensureImageDirectory() else {
|
||||
guard let destinationUrl = self.destinationURL(for: key) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let destinationUrl = directoryUrl.appendingPathComponent(key)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(from: imageUrl)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200
|
||||
else {
|
||||
self.log("Failed to download widget image: \(url)")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -43,46 +46,97 @@ public class SofaWidgetsSupportModule: Module {
|
||||
return destinationUrl.absoluteString
|
||||
}
|
||||
|
||||
AsyncFunction("copyBundledAsset") { (assetName: String, key: String) -> String? in
|
||||
guard let directoryUrl = self.ensureImageDirectory() else {
|
||||
AsyncFunction("copyBundledAsset") { (assetUri: String, key: String) -> String? in
|
||||
guard let destinationUrl = self.destinationURL(for: key) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let destinationUrl = directoryUrl.appendingPathComponent(key)
|
||||
|
||||
// Skip if already copied
|
||||
if FileManager.default.fileExists(atPath: destinationUrl.path) {
|
||||
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
|
||||
}
|
||||
|
||||
guard let pngData = image.pngData() else {
|
||||
return nil
|
||||
let data: Data
|
||||
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
|
||||
}
|
||||
|
||||
AsyncFunction("clearWidgetImages") {
|
||||
guard let containerUrl = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: groupIdentifier
|
||||
) else {
|
||||
guard let directoryUrl = self.imageDirectoryURL() else {
|
||||
return
|
||||
}
|
||||
|
||||
let directoryUrl = containerUrl.appendingPathComponent(imageDirectory)
|
||||
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(
|
||||
forSecurityApplicationGroupIdentifier: groupIdentifier
|
||||
) else {
|
||||
log("Unable to access app group container: \(groupIdentifier)")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,6 +148,40 @@ public class SofaWidgetsSupportModule: Module {
|
||||
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 {
|
||||
let size = image.size
|
||||
let scale = min(maxDimension / size.width, maxDimension / size.height, 1.0)
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
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",
|
||||
"format": "oxfmt --config ../../.oxfmtrc.json",
|
||||
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
|
||||
"check-types": "tsc --noEmit"
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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 { resolveUrl } from "@/lib/server";
|
||||
import { getWidgetIconAsset } from "@/lib/widget-assets";
|
||||
import type { ContinueWatchingProps } from "@/widgets/continue-watching";
|
||||
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. */
|
||||
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);
|
||||
if (!url) return null;
|
||||
return downloadWidgetImage(url, key);
|
||||
if (!url) return "";
|
||||
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 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"];
|
||||
|
||||
@@ -38,13 +66,51 @@ function formatEpisodeLabel(item: {
|
||||
return "TV";
|
||||
}
|
||||
|
||||
let iconFilePath: string | null = null;
|
||||
let refreshSequence = 0;
|
||||
|
||||
async function ensureIcon(): Promise<string | null> {
|
||||
if (!iconFilePath) {
|
||||
iconFilePath = await copyBundledAsset("sofa-icon", "sofa_icon");
|
||||
function hashString(value: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
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> {
|
||||
@@ -56,57 +122,69 @@ export async function refreshWidgets(): Promise<void> {
|
||||
import("@/widgets/upcoming"),
|
||||
]);
|
||||
|
||||
await ensureIcon();
|
||||
const iconFilePath = await ensureIcon();
|
||||
await Promise.all([
|
||||
refreshContinueWatching(ContinueWatchingWidget),
|
||||
refreshUpcoming(UpcomingWidget),
|
||||
refreshContinueWatching(ContinueWatchingWidget, iconFilePath),
|
||||
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(
|
||||
widget: Awaited<typeof import("@/widgets/continue-watching")>["default"],
|
||||
iconFilePath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { items } = await client.dashboard.continueWatching();
|
||||
|
||||
if (items.length === 0) {
|
||||
widget.updateSnapshot({
|
||||
isEmpty: true,
|
||||
iconFilePath,
|
||||
titleId: "",
|
||||
titleName: "",
|
||||
imageFilePath: null,
|
||||
watchedEpisodes: 0,
|
||||
totalEpisodes: 0,
|
||||
isMovie: false,
|
||||
});
|
||||
widget.updateSnapshot(
|
||||
sanitizeProps<ContinueWatchingProps>({
|
||||
iconFilePath,
|
||||
titleId: "",
|
||||
titleName: "",
|
||||
imageFilePath: "",
|
||||
watchedEpisodes: 0,
|
||||
totalEpisodes: 0,
|
||||
isMovie: false,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const top5 = items.slice(0, 5);
|
||||
const refreshToken = nextRefreshToken("cw");
|
||||
|
||||
const entries = await Promise.all(
|
||||
top5.map(async (item, index) => {
|
||||
const imageKey = `cw_${index}`;
|
||||
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 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 {
|
||||
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(
|
||||
widget: Awaited<typeof import("@/widgets/upcoming")>["default"],
|
||||
iconFilePath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { items } = await client.dashboard.upcoming({
|
||||
@@ -127,44 +206,51 @@ async function refreshUpcoming(
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
widget.updateSnapshot({
|
||||
isEmpty: true,
|
||||
iconFilePath,
|
||||
titleId: "",
|
||||
titleName: "",
|
||||
imageFilePath: null,
|
||||
titleType: "tv",
|
||||
episodeCount: 0,
|
||||
dateLabel: "",
|
||||
episodeLabel: "",
|
||||
});
|
||||
widget.updateSnapshot(
|
||||
sanitizeProps<UpcomingProps>({
|
||||
iconFilePath,
|
||||
titleId: "",
|
||||
titleName: "",
|
||||
imageFilePath: "",
|
||||
titleType: "tv",
|
||||
episodeCount: 0,
|
||||
dateLabel: "",
|
||||
episodeLabel: "",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const top5 = items.slice(0, 5);
|
||||
const refreshToken = nextRefreshToken("up");
|
||||
|
||||
const entries = await Promise.all(
|
||||
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 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 {
|
||||
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 = {
|
||||
titleId: string;
|
||||
titleName: string;
|
||||
imageFilePath: string | null;
|
||||
iconFilePath: string | null;
|
||||
imageFilePath: string;
|
||||
iconFilePath: string;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
watchedEpisodes: number;
|
||||
totalEpisodes: number;
|
||||
isMovie: boolean;
|
||||
isEmpty: boolean;
|
||||
};
|
||||
|
||||
const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnvironment) => {
|
||||
"widget";
|
||||
|
||||
if (props.isEmpty) {
|
||||
if (!props.titleName) {
|
||||
return (
|
||||
<VStack
|
||||
modifiers={[
|
||||
|
||||
@@ -14,21 +14,20 @@ import { createWidget, type WidgetEnvironment } from "expo-widgets";
|
||||
export type UpcomingProps = {
|
||||
titleId: string;
|
||||
titleName: string;
|
||||
imageFilePath: string | null;
|
||||
iconFilePath: string | null;
|
||||
imageFilePath: string;
|
||||
iconFilePath: string;
|
||||
titleType: "movie" | "tv";
|
||||
seasonNumber?: number | null;
|
||||
episodeNumber?: number | null;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
episodeCount: number;
|
||||
dateLabel: string;
|
||||
episodeLabel: string;
|
||||
isEmpty: boolean;
|
||||
};
|
||||
|
||||
const UpcomingWidget = (props: UpcomingProps, _env: WidgetEnvironment) => {
|
||||
"widget";
|
||||
|
||||
if (props.isEmpty) {
|
||||
if (!props.titleName) {
|
||||
return (
|
||||
<VStack
|
||||
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