Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
458 lines
18 KiB
Go
458 lines
18 KiB
Go
package workspace
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
preferencesbiz "github.com/tutti-os/tutti/services/tuttid/biz/preferences"
|
|
)
|
|
|
|
const desktopPreferencesRowID = "desktop"
|
|
|
|
func (s *SQLiteStore) GetDesktopPreferences(ctx context.Context) (preferencesbiz.DesktopPreferences, error) {
|
|
if s == nil || s.writeDB == nil {
|
|
return preferencesbiz.DesktopPreferences{}, errors.New("workspace database is not initialized")
|
|
}
|
|
|
|
row := s.readDB.QueryRowContext(ctx, `
|
|
SELECT agent_cli_update_check_enabled, default_agent_provider, agent_conversation_detail_mode, agent_dock_layout, dock_icon_style, dock_placement, deleted_agent_conversation_retention_days, locale, theme_source, sleep_prevention_mode, update_channel, update_policy, agent_composer_defaults_by_provider_json, agent_composer_defaults_by_agent_target_json, agent_gui_conversation_rail_collapsed_by_provider_json, agent_session_launch_modes_by_workspace_json, browser_use_connection_mode, file_default_openers_by_extension_json, app_catalog_channel, minimize_animation, show_app_developer_sources, workbench_window_snapping_enabled, workbench_window_snapping_shortcut_preset, feature_flags_json, workbench_shortcuts_json
|
|
FROM desktop_preferences
|
|
WHERE id = ?
|
|
`, desktopPreferencesRowID)
|
|
|
|
var agentCLIUpdateCheckEnabled bool
|
|
var defaultAgentProvider string
|
|
var agentConversationDetailMode string
|
|
var agentDockLayout string
|
|
var appCatalogChannel string
|
|
var browserUseConnectionMode string
|
|
var dockIconStyle string
|
|
var dockPlacement string
|
|
var deletedAgentConversationRetentionDays int
|
|
var locale string
|
|
var minimizeAnimation string
|
|
var showAppDeveloperSources bool
|
|
var windowSnappingEnabled bool
|
|
var windowSnappingShortcutPreset string
|
|
var featureFlagsJSON sql.NullString
|
|
var workbenchShortcutsJSON sql.NullString
|
|
var themeSource string
|
|
var sleepPreventionMode string
|
|
var updateChannel string
|
|
var updatePolicy string
|
|
var agentComposerDefaultsJSON string
|
|
var agentComposerDefaultsByAgentTargetJSON string
|
|
var agentGUIConversationRailCollapsedJSON string
|
|
var agentSessionLaunchModesJSON string
|
|
var fileDefaultOpenersJSON string
|
|
if err := row.Scan(&agentCLIUpdateCheckEnabled, &defaultAgentProvider, &agentConversationDetailMode, &agentDockLayout, &dockIconStyle, &dockPlacement, &deletedAgentConversationRetentionDays, &locale, &themeSource, &sleepPreventionMode, &updateChannel, &updatePolicy, &agentComposerDefaultsJSON, &agentComposerDefaultsByAgentTargetJSON, &agentGUIConversationRailCollapsedJSON, &agentSessionLaunchModesJSON, &browserUseConnectionMode, &fileDefaultOpenersJSON, &appCatalogChannel, &minimizeAnimation, &showAppDeveloperSources, &windowSnappingEnabled, &windowSnappingShortcutPreset, &featureFlagsJSON, &workbenchShortcutsJSON); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return preferencesbiz.DefaultDesktopPreferences(), nil
|
|
}
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("get desktop preferences: %w", err)
|
|
}
|
|
agentComposerDefaults, err := decodeAgentComposerDefaultsByProvider(agentComposerDefaultsJSON)
|
|
if err != nil {
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("decode desktop preferences agent composer defaults: %w", err)
|
|
}
|
|
agentComposerDefaultsByAgentTarget, err := decodeAgentComposerDefaultsByProvider(agentComposerDefaultsByAgentTargetJSON)
|
|
if err != nil {
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("decode desktop preferences agent composer defaults by agent target: %w", err)
|
|
}
|
|
agentGUIConversationRailCollapsed, err := decodeAgentGUIConversationRailCollapsedByProvider(agentGUIConversationRailCollapsedJSON)
|
|
if err != nil {
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("decode desktop preferences agent gui conversation rail: %w", err)
|
|
}
|
|
agentSessionLaunchModes, err := decodeAgentSessionLaunchModesByWorkspace(agentSessionLaunchModesJSON)
|
|
if err != nil {
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("decode desktop preferences Agent Session launch modes: %w", err)
|
|
}
|
|
fileDefaultOpeners, err := decodeFileDefaultOpenersByExtension(fileDefaultOpenersJSON)
|
|
if err != nil {
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("decode desktop preferences file default openers: %w", err)
|
|
}
|
|
featureFlags, err := decodeFeatureFlags(featureFlagsJSON.String)
|
|
if err != nil {
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("decode desktop preferences feature flags: %w", err)
|
|
}
|
|
workbenchShortcuts := decodeWorkbenchShortcuts(workbenchShortcutsJSON.String)
|
|
|
|
return preferencesbiz.DesktopPreferences{
|
|
AgentCLIUpdateCheckEnabled: agentCLIUpdateCheckEnabled,
|
|
AgentComposerDefaultsByProvider: agentComposerDefaults,
|
|
AgentComposerDefaultsByAgentTarget: agentComposerDefaultsByAgentTarget,
|
|
AgentGUIConversationRailCollapsedByProvider: agentGUIConversationRailCollapsed,
|
|
AgentSessionLaunchModesByWorkspace: agentSessionLaunchModes,
|
|
AgentConversationDetailMode: preferencesbiz.NormalizeDesktopAgentConversationDetailMode(agentConversationDetailMode),
|
|
AgentDockLayout: preferencesbiz.NormalizeDesktopAgentDockLayout(agentDockLayout),
|
|
AppCatalogChannel: appCatalogChannel,
|
|
BrowserUseConnectionMode: browserUseConnectionMode,
|
|
DefaultAgentProvider: defaultAgentProvider,
|
|
DockIconStyle: dockIconStyle,
|
|
DockPlacement: dockPlacement,
|
|
DeletedAgentConversationRetentionDays: preferencesbiz.NormalizeDeletedAgentConversationRetentionDays(deletedAgentConversationRetentionDays),
|
|
FeatureFlags: featureFlags,
|
|
FileDefaultOpenersByExtension: fileDefaultOpeners,
|
|
Initialized: true,
|
|
Locale: locale,
|
|
MinimizeAnimation: minimizeAnimation,
|
|
SleepPreventionMode: sleepPreventionMode,
|
|
ShowAppDeveloperSources: showAppDeveloperSources,
|
|
ThemeSource: themeSource,
|
|
UpdateChannel: updateChannel,
|
|
UpdatePolicy: updatePolicy,
|
|
WindowSnappingEnabled: windowSnappingEnabled,
|
|
WindowSnappingShortcutPreset: windowSnappingShortcutPreset,
|
|
WorkbenchShortcuts: workbenchShortcuts,
|
|
}, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) PatchAgentComposerDefaultsForTarget(
|
|
ctx context.Context,
|
|
agentTargetID string,
|
|
patch preferencesbiz.AgentComposerDefaultsPatch,
|
|
) (preferencesbiz.AgentComposerDefaults, error) {
|
|
if s == nil || s.writeDB == nil {
|
|
return preferencesbiz.AgentComposerDefaults{}, errors.New("workspace database is not initialized")
|
|
}
|
|
agentTargetID = strings.TrimSpace(agentTargetID)
|
|
if agentTargetID == "" || len(patch) == 0 {
|
|
return preferencesbiz.AgentComposerDefaults{}, errors.New("agent composer defaults patch is empty")
|
|
}
|
|
|
|
tx, err := s.writeDB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return preferencesbiz.AgentComposerDefaults{}, fmt.Errorf("begin agent composer defaults patch: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
var raw string
|
|
if err := tx.QueryRowContext(ctx, `
|
|
SELECT agent_composer_defaults_by_agent_target_json
|
|
FROM desktop_preferences
|
|
WHERE id = ?
|
|
`, desktopPreferencesRowID).Scan(&raw); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return preferencesbiz.AgentComposerDefaults{}, fmt.Errorf("read agent composer defaults for patch: %w", ErrDesktopPreferencesNotInitialized)
|
|
}
|
|
return preferencesbiz.AgentComposerDefaults{}, fmt.Errorf("read agent composer defaults for patch: %w", err)
|
|
}
|
|
defaultsByTarget, err := decodeAgentComposerDefaultsByProvider(raw)
|
|
if err != nil {
|
|
return preferencesbiz.AgentComposerDefaults{}, fmt.Errorf("decode agent composer defaults for patch: %w", err)
|
|
}
|
|
defaults := defaultsByTarget[agentTargetID]
|
|
for field, value := range patch {
|
|
switch field {
|
|
case preferencesbiz.AgentComposerDefaultsFieldCodexSaverMode:
|
|
next, ok := value.(bool)
|
|
if !ok {
|
|
return preferencesbiz.AgentComposerDefaults{}, fmt.Errorf("agent composer defaults field %q must be boolean", field)
|
|
}
|
|
defaults.CodexSaverMode = next
|
|
case preferencesbiz.AgentComposerDefaultsFieldRTKSaverMode:
|
|
next, ok := value.(bool)
|
|
if !ok {
|
|
return preferencesbiz.AgentComposerDefaults{}, fmt.Errorf("agent composer defaults field %q must be boolean", field)
|
|
}
|
|
defaults.RTKSaverMode = next
|
|
case preferencesbiz.AgentComposerDefaultsFieldModel:
|
|
defaults.Model, err = agentComposerDefaultsTextPatchValue(value)
|
|
case preferencesbiz.AgentComposerDefaultsFieldPermissionModeID:
|
|
defaults.PermissionModeID, err = agentComposerDefaultsTextPatchValue(value)
|
|
case preferencesbiz.AgentComposerDefaultsFieldReasoningEffort:
|
|
defaults.ReasoningEffort, err = agentComposerDefaultsTextPatchValue(value)
|
|
case preferencesbiz.AgentComposerDefaultsFieldSpeed:
|
|
defaults.Speed, err = agentComposerDefaultsTextPatchValue(value)
|
|
default:
|
|
return preferencesbiz.AgentComposerDefaults{}, fmt.Errorf("unsupported agent composer defaults field %q", field)
|
|
}
|
|
if err != nil {
|
|
return preferencesbiz.AgentComposerDefaults{}, fmt.Errorf("agent composer defaults field %q: %w", field, err)
|
|
}
|
|
}
|
|
if defaults.IsZero() {
|
|
delete(defaultsByTarget, agentTargetID)
|
|
} else {
|
|
defaultsByTarget[agentTargetID] = defaults
|
|
}
|
|
encoded, err := encodeAgentComposerDefaultsByProvider(defaultsByTarget)
|
|
if err != nil {
|
|
return preferencesbiz.AgentComposerDefaults{}, fmt.Errorf("encode agent composer defaults patch: %w", err)
|
|
}
|
|
result, err := tx.ExecContext(ctx, `
|
|
UPDATE desktop_preferences
|
|
SET agent_composer_defaults_by_agent_target_json = ?, updated_at_unix_ms = ?
|
|
WHERE id = ?
|
|
`, encoded, unixMs(time.Now().UTC()), desktopPreferencesRowID)
|
|
if err != nil {
|
|
return preferencesbiz.AgentComposerDefaults{}, fmt.Errorf("write agent composer defaults patch: %w", err)
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil {
|
|
return preferencesbiz.AgentComposerDefaults{}, fmt.Errorf("count agent composer defaults patch rows: %w", err)
|
|
}
|
|
if rows != 1 {
|
|
return preferencesbiz.AgentComposerDefaults{}, ErrDesktopPreferencesNotInitialized
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return preferencesbiz.AgentComposerDefaults{}, fmt.Errorf("commit agent composer defaults patch: %w", err)
|
|
}
|
|
return defaults, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) PatchAgentSessionLaunchMode(
|
|
ctx context.Context,
|
|
workspaceID string,
|
|
projectSectionKey string,
|
|
mode string,
|
|
) (preferencesbiz.DesktopPreferences, error) {
|
|
if s == nil || s.writeDB == nil {
|
|
return preferencesbiz.DesktopPreferences{}, errors.New("workspace database is not initialized")
|
|
}
|
|
workspaceID = strings.TrimSpace(workspaceID)
|
|
projectSectionKey = strings.TrimSpace(projectSectionKey)
|
|
mode = strings.TrimSpace(mode)
|
|
if workspaceID == "" || projectSectionKey == "" || (mode != "local" && mode != "worktree") {
|
|
return preferencesbiz.DesktopPreferences{}, errors.New("agent session launch mode patch is invalid")
|
|
}
|
|
|
|
tx, err := s.writeDB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("begin agent session launch mode patch: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
var raw string
|
|
if err := tx.QueryRowContext(ctx, `
|
|
SELECT agent_session_launch_modes_by_workspace_json
|
|
FROM desktop_preferences
|
|
WHERE id = ?
|
|
`, desktopPreferencesRowID).Scan(&raw); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("read agent session launch modes for patch: %w", ErrDesktopPreferencesNotInitialized)
|
|
}
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("read agent session launch modes for patch: %w", err)
|
|
}
|
|
modesByWorkspace, err := decodeAgentSessionLaunchModesByWorkspace(raw)
|
|
if err != nil {
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("decode agent session launch modes for patch: %w", err)
|
|
}
|
|
if modesByWorkspace == nil {
|
|
modesByWorkspace = map[string]map[string]string{}
|
|
}
|
|
modesByProject := modesByWorkspace[workspaceID]
|
|
if modesByProject == nil {
|
|
modesByProject = map[string]string{}
|
|
}
|
|
modesByProject[projectSectionKey] = mode
|
|
modesByWorkspace[workspaceID] = modesByProject
|
|
encoded, err := encodeAgentSessionLaunchModesByWorkspace(modesByWorkspace)
|
|
if err != nil {
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("encode agent session launch modes patch: %w", err)
|
|
}
|
|
result, err := tx.ExecContext(ctx, `
|
|
UPDATE desktop_preferences
|
|
SET agent_session_launch_modes_by_workspace_json = ?, updated_at_unix_ms = ?
|
|
WHERE id = ?
|
|
`, encoded, unixMs(time.Now().UTC()), desktopPreferencesRowID)
|
|
if err != nil {
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("write agent session launch mode patch: %w", err)
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil {
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("count agent session launch mode patch rows: %w", err)
|
|
}
|
|
if rows != 1 {
|
|
return preferencesbiz.DesktopPreferences{}, ErrDesktopPreferencesNotInitialized
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return preferencesbiz.DesktopPreferences{}, fmt.Errorf("commit agent session launch mode patch: %w", err)
|
|
}
|
|
return s.GetDesktopPreferences(ctx)
|
|
}
|
|
|
|
func agentComposerDefaultsTextPatchValue(value any) (string, error) {
|
|
if value == nil {
|
|
return "", nil
|
|
}
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return strings.TrimSpace(typed), nil
|
|
case *string:
|
|
if typed != nil {
|
|
return strings.TrimSpace(*typed), nil
|
|
}
|
|
return "", nil
|
|
}
|
|
return "", fmt.Errorf("must be a string or null")
|
|
}
|
|
|
|
func decodeFileDefaultOpenersByExtension(raw string) (map[string]string, error) {
|
|
if raw == "" {
|
|
return preferencesbiz.DefaultDesktopPreferences().FileDefaultOpenersByExtension, nil
|
|
}
|
|
var decoded map[string]string
|
|
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
|
return nil, err
|
|
}
|
|
if decoded == nil {
|
|
return preferencesbiz.DefaultDesktopPreferences().FileDefaultOpenersByExtension, nil
|
|
}
|
|
return decoded, nil
|
|
}
|
|
|
|
func encodeFileDefaultOpenersByExtension(value map[string]string) (string, error) {
|
|
if value == nil {
|
|
value = preferencesbiz.DefaultDesktopPreferences().FileDefaultOpenersByExtension
|
|
}
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
func decodeAgentGUIConversationRailCollapsedByProvider(raw string) (map[string]bool, error) {
|
|
if raw == "" {
|
|
return map[string]bool{}, nil
|
|
}
|
|
var decoded map[string]bool
|
|
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
|
return nil, err
|
|
}
|
|
if decoded == nil {
|
|
return map[string]bool{}, nil
|
|
}
|
|
return decoded, nil
|
|
}
|
|
|
|
func encodeAgentGUIConversationRailCollapsedByProvider(value map[string]bool) (string, error) {
|
|
if value == nil {
|
|
value = map[string]bool{}
|
|
}
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
func decodeAgentSessionLaunchModesByWorkspace(raw string) (map[string]map[string]string, error) {
|
|
if raw == "" {
|
|
return map[string]map[string]string{}, nil
|
|
}
|
|
var decoded map[string]map[string]string
|
|
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
|
return nil, err
|
|
}
|
|
if decoded == nil {
|
|
return map[string]map[string]string{}, nil
|
|
}
|
|
return decoded, nil
|
|
}
|
|
|
|
func encodeAgentSessionLaunchModesByWorkspace(value map[string]map[string]string) (string, error) {
|
|
if value == nil {
|
|
value = map[string]map[string]string{}
|
|
}
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
func decodeAgentComposerDefaultsByProvider(raw string) (map[string]preferencesbiz.AgentComposerDefaults, error) {
|
|
if raw == "" {
|
|
return map[string]preferencesbiz.AgentComposerDefaults{}, nil
|
|
}
|
|
var decoded map[string]preferencesbiz.AgentComposerDefaults
|
|
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
|
return nil, err
|
|
}
|
|
if decoded == nil {
|
|
return map[string]preferencesbiz.AgentComposerDefaults{}, nil
|
|
}
|
|
return decoded, nil
|
|
}
|
|
|
|
func encodeAgentComposerDefaultsByProvider(value map[string]preferencesbiz.AgentComposerDefaults) (string, error) {
|
|
if value == nil {
|
|
value = map[string]preferencesbiz.AgentComposerDefaults{}
|
|
}
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
func decodeFeatureFlags(raw string) (map[string]bool, error) {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return map[string]bool{}, nil
|
|
}
|
|
var decoded map[string]bool
|
|
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
|
return map[string]bool{}, nil // tolerate corrupt JSON
|
|
}
|
|
return preferencesbiz.NormalizeDesktopFeatureFlags(decoded), nil
|
|
}
|
|
|
|
func encodeFeatureFlags(value map[string]bool) (string, error) {
|
|
data, err := json.Marshal(preferencesbiz.NormalizeDesktopFeatureFlags(value))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
func decodeWorkbenchShortcuts(raw string) preferencesbiz.DesktopWorkbenchShortcuts {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return preferencesbiz.DesktopWorkbenchShortcuts{}
|
|
}
|
|
var decoded struct {
|
|
NewAgentConversation *string `json:"newAgentConversation"`
|
|
NewSameTypeWindow *string `json:"newSameTypeWindow"`
|
|
CaptureScreenshot *string `json:"captureScreenshot"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
|
return preferencesbiz.DesktopWorkbenchShortcuts{}
|
|
}
|
|
deref := func(p *string) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
return *p
|
|
}
|
|
return preferencesbiz.NormalizeDesktopWorkbenchShortcuts(preferencesbiz.DesktopWorkbenchShortcuts{
|
|
NewAgentConversation: deref(decoded.NewAgentConversation),
|
|
NewSameTypeWindow: deref(decoded.NewSameTypeWindow),
|
|
CaptureScreenshot: deref(decoded.CaptureScreenshot),
|
|
})
|
|
}
|
|
|
|
func encodeWorkbenchShortcuts(value preferencesbiz.DesktopWorkbenchShortcuts) (string, error) {
|
|
n := preferencesbiz.NormalizeDesktopWorkbenchShortcuts(value)
|
|
ptr := func(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|
|
data, err := json.Marshal(struct {
|
|
NewAgentConversation *string `json:"newAgentConversation"`
|
|
NewSameTypeWindow *string `json:"newSameTypeWindow"`
|
|
CaptureScreenshot *string `json:"captureScreenshot,omitempty"`
|
|
}{ptr(n.NewAgentConversation), ptr(n.NewSameTypeWindow), ptr(n.CaptureScreenshot)})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|