Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PolyPilot.Tests/PolyPilot.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<Compile Include="../PolyPilot/Models/BridgeMessages.cs" Link="Shared/BridgeMessages.cs" />
<Compile Include="../PolyPilot/Models/ConnectionSettings.cs" Link="Shared/ConnectionSettings.cs" />
<Compile Include="../PolyPilot/Models/PlatformHelper.cs" Link="Shared/PlatformHelper.cs" />
<Compile Include="../PolyPilot/Models/PlatformPaths.cs" Link="Shared/PlatformPaths.cs" />
<Compile Include="../PolyPilot/Models/SessionOrganization.cs" Link="Shared/SessionOrganization.cs" />
<Compile Include="../PolyPilot/Models/FiestaModels.cs" Link="Shared/FiestaModels.cs" />
<Compile Include="../PolyPilot/Models/ModelHelper.cs" Link="Shared/ModelHelper.cs" />
Expand Down
6 changes: 4 additions & 2 deletions PolyPilot.Tests/TestSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ namespace PolyPilot.Tests;
/// If you add new file paths to CopilotService or any service that persists state,
/// you MUST also redirect them in Initialize() or they will leak to the real filesystem.
///
/// Currently isolated: CopilotService BaseDir/CaptureDir, RepoManager, AuditLogService,
/// PromptLibraryService, FiestaService state file, ConnectionSettings settings file.
/// Currently isolated: CopilotService BaseDir/CaptureDir, PlatformPaths, RepoManager,
/// AuditLogService, PromptLibraryService, FiestaService state file, ConnectionSettings
/// settings file, PluginLoader plugins dir, ShowImageTool images dir.
/// </summary>
internal static class TestSetup
{
Expand All @@ -30,6 +31,7 @@ internal static void Initialize()
Directory.CreateDirectory(TestBaseDir);
CopilotService.SetBaseDirForTesting(TestBaseDir);
CopilotService.SetCaptureDirForTesting(Path.Combine(TestBaseDir, "zero-idle-captures"));
PlatformPaths.SetForTesting(TestBaseDir);
RepoManager.SetBaseDirForTesting(TestBaseDir);
AuditLogService.SetLogDirForTesting(Path.Combine(TestBaseDir, "audit_logs"));
PromptLibraryService.SetUserPromptsDirForTesting(Path.Combine(TestBaseDir, "prompts"));
Expand Down
10 changes: 7 additions & 3 deletions PolyPilot/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using PolyPilot.Models;
using PolyPilot.Services;

namespace PolyPilot;
Expand Down Expand Up @@ -72,9 +73,12 @@ private void CheckPendingNavigation()
{
try
{
var navPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".polypilot", "pending-navigation.json");
var sandboxPath = PlatformPaths.GetPolyPilotDirOverride();
var baseDir = sandboxPath
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".polypilot");
var navPath = Path.Combine(baseDir, "pending-navigation.json");

if (!File.Exists(navPath))
return;
Expand Down
4 changes: 4 additions & 0 deletions PolyPilot/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ private static string GetCrashLogPath()
{
try
{
var sandboxPath = PlatformPaths.GetPolyPilotDirOverride();
if (sandboxPath != null)
return Path.Combine(sandboxPath, "crash.log");

#if ANDROID || IOS
return Path.Combine(FileSystem.AppDataDirectory, ".polypilot", "crash.log");
#else
Expand Down
3 changes: 3 additions & 0 deletions PolyPilot/Models/ConnectionSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ internal static Dictionary<string, string> BuildDirectQrPayload(

private static string GetPolyPilotDir()
{
var sandboxPath = PlatformPaths.GetPolyPilotDirOverride();
if (sandboxPath != null) return sandboxPath;

#if IOS || ANDROID
try
{
Expand Down
46 changes: 46 additions & 0 deletions PolyPilot/Models/PlatformPaths.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace PolyPilot.Models;

/// <summary>
/// Platform-specific path resolution and test isolation for file paths.
///
/// On Mac App Store (MACCATALYST sandbox):
/// The sandbox remaps HOME (UserProfile) into ~/Library/Containers/&lt;bundle-id&gt;/Data/,
/// so both ~/.polypilot/ and ~/.copilot/ resolve inside the container automatically.
/// No override is needed — callers use their existing UserProfile-based logic unchanged.
///
/// On all other platforms: returns null (callers use their existing logic unchanged).
///
/// The primary value of this class is providing a centralized test override via
/// <see cref="SetForTesting"/> so tests never touch the real filesystem.
/// </summary>
public static class PlatformPaths
{
private static string? _testPolyPilotDir;

/// <summary>
/// Test-only: override returned path to prevent tests from touching real filesystem.
/// Also clears static caches in services that use <c>??=</c> patterns (PluginLoader, ShowImageTool)
/// so the override takes effect even if the cache was previously populated.
/// </summary>
internal static void SetForTesting(string? polyPilotDir)
{
_testPolyPilotDir = polyPilotDir;
// Clear static caches that use ??= so the override takes effect
Services.PluginLoader.ResetCachedPathForTesting();
Services.ShowImageTool.ResetCachedPathForTesting();
}

/// <summary>
/// Get the PolyPilot configuration directory (~/.polypilot/) override for the current platform.
/// Returns a test override if set, otherwise null on all platforms.
/// On MACCATALYST the sandbox remaps UserProfile into the container, so the
/// existing UserProfile-based paths resolve correctly without an override.
/// </summary>
public static string? GetPolyPilotDirOverride()
{
if (_testPolyPilotDir != null) return _testPolyPilotDir;
// Mac Catalyst sandbox remaps HOME (UserProfile) into the container.
// ~/.polypilot/ already resolves inside the sandbox. No override needed.
return null;
}
}
16 changes: 12 additions & 4 deletions PolyPilot/Platforms/MacCatalyst/NotificationManagerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,18 @@ internal void OnNotificationTapped(string? sessionId)
NotificationTapped?.Invoke(this, new NotificationTappedEventArgs { SessionId = sessionId });
}

private static string PendingNavigationPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".polypilot", "pending-navigation.json");
private static string PendingNavigationPath
{
get
{
var sandboxPath = PolyPilot.Models.PlatformPaths.GetPolyPilotDirOverride();
var baseDir = sandboxPath
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".polypilot");
return Path.Combine(baseDir, "pending-navigation.json");
}
}

private static void WritePendingNavigation(string sessionId)
{
Expand Down
16 changes: 10 additions & 6 deletions PolyPilot/Platforms/MacCatalyst/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ static bool TryAcquireInstanceLock()
{
try
{
var lockDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".polypilot");
var sandboxPath = PolyPilot.Models.PlatformPaths.GetPolyPilotDirOverride();
var lockDir = sandboxPath
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".polypilot");
Directory.CreateDirectory(lockDir);
var lockPath = Path.Combine(lockDir, "instance.lock");

Expand Down Expand Up @@ -69,9 +71,11 @@ static void ActivateExistingInstance(string[] args)
{
try
{
var navDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".polypilot");
var sandboxPath = PolyPilot.Models.PlatformPaths.GetPolyPilotDirOverride();
var navDir = sandboxPath
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".polypilot");
Directory.CreateDirectory(navDir);
var navPath = Path.Combine(navDir, "pending-navigation.json");
// Include writtenAt so the 30s TTL in CheckPendingNavigation applies if the
Expand Down
4 changes: 4 additions & 0 deletions PolyPilot/Services/AuditLogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ private static string ComputeAuditLogDir()
{
try
{
var sandboxPath = PlatformPaths.GetPolyPilotDirOverride();
if (sandboxPath != null)
return Path.Combine(sandboxPath, LogDirName);

#if IOS || ANDROID
return Path.Combine(FileSystem.AppDataDirectory, ".polypilot", LogDirName);
#else
Expand Down
4 changes: 4 additions & 0 deletions PolyPilot/Services/ChatDatabase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ private static string GetDbPath()
{
try
{
var sandboxPath = PlatformPaths.GetPolyPilotDirOverride();
if (sandboxPath != null)
return Path.Combine(sandboxPath, "chat_history.db");

var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(home))
home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
Expand Down
4 changes: 4 additions & 0 deletions PolyPilot/Services/CopilotService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ private static string GetPolyPilotBaseDir()
{
try
{
// Mac App Store sandbox: use app container
var sandboxPath = PlatformPaths.GetPolyPilotDirOverride();
if (sandboxPath != null) return sandboxPath;

#if ANDROID
var home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrEmpty(home))
Expand Down
13 changes: 11 additions & 2 deletions PolyPilot/Services/PluginFileLogger.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using PolyPilot.Models;
using PolyPilot.Provider;

namespace PolyPilot.Services;
Expand All @@ -16,8 +17,16 @@ public class PluginFileLogger : IPluginLogger
public PluginFileLogger(string pluginName)
{
_pluginName = pluginName;
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var dir = Path.Combine(home, ".polypilot", "logs", "plugins", pluginName);
string baseDir;
var sandboxPath = PlatformPaths.GetPolyPilotDirOverride();
if (sandboxPath != null)
baseDir = sandboxPath;
else
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
baseDir = Path.Combine(home, ".polypilot");
}
var dir = Path.Combine(baseDir, "logs", "plugins", pluginName);
Directory.CreateDirectory(dir);
_logPath = Path.Combine(dir, "plugin.log");
}
Expand Down
14 changes: 12 additions & 2 deletions PolyPilot/Services/PluginLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,18 @@ namespace PolyPilot.Services;
public static class PluginLoader
{
private static string? _pluginsDir;
private static string PluginsDir => _pluginsDir ??= Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".polypilot", "plugins");
private static string PluginsDir => _pluginsDir ??= ComputePluginsDir();

private static string ComputePluginsDir()
{
var sandboxPath = PlatformPaths.GetPolyPilotDirOverride();
if (sandboxPath != null) return Path.Combine(sandboxPath, "plugins");
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".polypilot", "plugins");
}

/// <summary>Test-only: clear the cached path so <see cref="PlatformPaths.SetForTesting"/> takes effect.</summary>
internal static void ResetCachedPathForTesting() => _pluginsDir = null;

/// <summary>
/// Scans the plugins directory for subdirectories containing a plugin.json manifest.
Expand Down
3 changes: 3 additions & 0 deletions PolyPilot/Services/PromptLibraryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ public class PromptLibraryService

private static string GetPolyPilotDir()
{
var sandboxPath = PlatformPaths.GetPolyPilotDirOverride();
if (sandboxPath != null) return sandboxPath;

#if IOS || ANDROID
try
{
Expand Down
3 changes: 3 additions & 0 deletions PolyPilot/Services/RepoManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ private static string GetBaseDir()
if (over != null) return over;
try
{
var sandboxPath = PlatformPaths.GetPolyPilotDirOverride();
if (sandboxPath != null) return sandboxPath;

var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(home))
home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
Expand Down
3 changes: 3 additions & 0 deletions PolyPilot/Services/ScheduledTaskService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,9 @@ private static bool IsSuccessfulCompletionSummary(string? summary)

private static string GetPolyPilotDir()
{
var sandboxPath = PlatformPaths.GetPolyPilotDirOverride();
if (sandboxPath != null) return sandboxPath;

#if IOS || ANDROID
try
{
Expand Down
3 changes: 3 additions & 0 deletions PolyPilot/Services/ServerManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public class ServerManager : IServerManager

private static string GetPolyPilotDir()
{
var sandboxPath = PlatformPaths.GetPolyPilotDirOverride();
if (sandboxPath != null) return sandboxPath;

var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(home))
home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
Expand Down
15 changes: 13 additions & 2 deletions PolyPilot/Services/ShowImageTool.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.ComponentModel;
using System.Text.Json;
using Microsoft.Extensions.AI;
using PolyPilot.Models;

namespace PolyPilot.Services;

Expand All @@ -15,8 +16,18 @@ public static class ShowImageTool
private static readonly string[] SupportedExtensions = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg" };

private static string? _imagesDir;
private static string ImagesDir => _imagesDir ??= Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".polypilot", "images");
private static string ImagesDir => _imagesDir ??= ComputeImagesDir();

private static string ComputeImagesDir()
{
var sandboxPath = PlatformPaths.GetPolyPilotDirOverride();
if (sandboxPath != null) return Path.Combine(sandboxPath, "images");
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".polypilot", "images");
}

/// <summary>Test-only: clear the cached path so <see cref="PlatformPaths.SetForTesting"/> takes effect.</summary>
internal static void ResetCachedPathForTesting() => _imagesDir = null;

/// <summary>Returns the images directory path. Used by FetchImage validation.</summary>
public static string GetImagesDir() => ImagesDir;
Expand Down
Loading